diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 1a1810b5..8cc16e7a 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(audit): record enterprise mutations`. -- Latest documentation checkpoint: this checkpoint, recorded with the B10.2a audit writer implementation. -- Worktree status at this implementation checkpoint: B10.2a audit writer boundary is wired for automation rules, macros, custom roles, and CSAT review-note mutations; next active slice is B10.2b inbox/conversation/SLA/capacity writer depth and B10.3 CustomRole permission parity. +- Latest implementation checkpoint: `feat(audit): cover operational mutations`. +- Latest documentation checkpoint: this checkpoint, recorded with the B10.2b audit writer implementation. +- Worktree status at this implementation checkpoint: B10.2 audit writer coverage now includes automation rules, macros, custom roles, CSAT review notes, inbox create/update, conversation update/assignment/status/delete, SLA policy CRUD, AgentCapacityPolicy CRUD, capacity users, and inbox capacity limits; next active slice is B10.3 CustomRole permission parity. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -44,11 +44,10 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B10.2b | Inbox/conversation/SLA/capacity audit writer coverage is wired through the shared audit boundary. | Representative mutation audit tests with actor/account/request metadata. | -| 2 | B10.3 | CustomRole permission behavior and AccountUser role resolution match Chatwoot. | Permission matrix tests plus admin/non-admin handler tests. | -| 3 | B10.4 | Remaining InboxLimit/account-limit create-path behavior is aligned. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | -| 4 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. | -| 5 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | +| 1 | B10.3 | CustomRole permission behavior and AccountUser role resolution match Chatwoot. | Permission matrix tests plus admin/non-admin handler tests. | +| 2 | B10.4 | Remaining InboxLimit/account-limit create-path behavior is aligned. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | +| 3 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. | +| 4 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | ## Execution Snapshot @@ -144,12 +143,13 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(macros): align chatwoot macro payloads` | Completed B9.2 macro CRUD/execute parity for the reused frontend: macro list/show/create/update now return Chatwoot `{ payload }` serializers with string visibility and nested created/updated agents, create/update accept Chatwoot array action params, public macro permissions follow `MacroPolicy`, delete/execute return empty `200 OK`, and execute resolves `conversation_ids` by account-scoped display ID before mutating conversations. | `go test ./internal/handler/api/v1 -run Macro -count=1`; `go test ./internal/automation -run Macro -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B10 audit/custom roles/remaining limits; keep B9.3 durable delayed worker scheduling as Phase 5 follow-up. | | `feat(audit): align chatwoot audit log payloads` | Completed B10.1 audit log list parity for the reused enterprise settings screen: `/audit_logs` now returns Chatwoot top-level `per_page`, `total_entries`, `current_page`, and `audit_logs`; pagination is fixed at 25 per page; list/get require administrator/super_admin role; account scoping matches associated audits as well as local `account_id`; and serializer fields match the enterprise Jbuilder shape with Unix `created_at`. | `go test ./internal/handler/api/v1 -run Audit -count=1`; `go test ./internal/repository -run Audit -count=1`; `go test ./internal/service -run Audit -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B10.2 audit writer coverage, then B10.3 CustomRole permission-key/account-user parity. | | `feat(audit): record enterprise mutations` | Completed B10.2a audit writer boundary and first enterprise mutation coverage: `AuditService.Record` now creates account-associated audit rows with actor, request UUID, remote address, action, auditable type/id, and JSON changes; automation-rule create/update/delete/clone/toggle, macro create/update/delete, custom-role create/update/delete, and CSAT review-note update call the shared writer. | `go test ./internal/handler/api/v1 -run 'CustomRole\|AutomationRule\|Macro\|CsatSurvey' -count=1`; `go test ./internal/service -run Audit -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B10.2b inbox/conversation/SLA/capacity audit writer coverage, then B10.3 CustomRole permission parity. | +| `feat(audit): cover operational mutations` | Completed B10.2b audit writer coverage for the remaining named operational mutations: inbox create/update, conversation update/delete/assign/status, SLA policy create/update/delete, AgentCapacityPolicy create/update/delete, inbox capacity limit create/update/delete, and capacity-policy user assignment/removal now call the shared audit writer. | `go test ./internal/handler/api/v1 -run 'SlaPolicy\|AgentCapacity\|Inbox\|Conversation' -count=1`; `go test ./internal/service -run Audit -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B10.3 CustomRole permission-key/account-user parity and B10.4 remaining InboxLimit create-path enforcement. | ## Next Slice Contract -Completed implementation slice: B10.2a now covers the shared audit writer boundary and first enterprise mutation set: automation rules, macros, custom roles, and CSAT review notes. +Completed implementation slice: B10.2 now covers the shared audit writer boundary and the named representative enterprise/core mutation set from the tracker. -Next implementation slice: continue B10.2b audit writer coverage for inbox/channel, conversation assignment/status, SLA policy, and AgentCapacityPolicy mutations, then B10.3 CustomRole permission-key/account-user parity. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. +Next implementation slice: continue B10.3 CustomRole permission-key/account-user parity, then B10.4 remaining InboxLimit create-path enforcement. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -394,7 +394,7 @@ Upcoming enterprise task boards: | B9 | B9.3 | Add delayed action scheduling and durable worker parity after the B9.1d synchronous retry boundary lands. | Chatwoot automation jobs, delayed action handling, and Phase 5 worker plan. | Worker tests cover queued delayed actions, retries, and observable failures. | Todo | | B9 | B9.4 | Review deeper macro attachments/files and durable queued execution once the worker path is selected. | Chatwoot macro attachments and `MacrosExecutionJob`. | Attachment/file tests or explicit durable-worker split. | Todo | | B10 | B10.1 | Align audit log serializer, filters, pagination, actor/request metadata, and admin route behavior. | Chatwoot enterprise audit controllers/models. | Audit list tests plus representative mutation writer tests. | Done by `feat(audit): align chatwoot audit log payloads`; writer coverage continues in B10.2 | -| B10 | B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Doing; automation/macro/custom-role/CSAT review-note writers done by `feat(audit): record enterprise mutations` | +| B10 | B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Done by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations` | | B10 | B10.3 | Align CustomRole permission keys, account-user role resolution, deletion nullification, and authorization failure payloads. | Chatwoot custom role controllers/policies and permission constants. | Permission matrix tests for admin/non-admin/custom-role access. | Todo | | B10 | B10.4 | Review remaining InboxLimit/account-limit create-path enforcement outside capacity policies. | Chatwoot enterprise account/inbox limit policies. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | Todo | | B11 | B11.1 | Align Captain assistant CRUD, inbox bindings, responses, documents, scenarios, and custom tools payloads. | Captain controllers/services/frontend clients under `reference/chatwoot`. | Handler/service fixtures for every Captain dashboard client path. | Todo | @@ -611,7 +611,7 @@ B10 admin and limits breakdown: | Step | Implementation target | Reference source | Required tests | Status | | --- | --- | --- | --- | --- | | B10.1 | Align audit list filters, serializer fields, and pagination before broadening writers. | Chatwoot enterprise audit controller/model. | Audit handler tests for account scope, action/auditable filters, actor metadata, and pagination. | Done by `feat(audit): align chatwoot audit log payloads` | -| B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Doing; B10.2a covers automation/macro/custom-role/CSAT review notes | +| B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Done by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations` | | B10.3 | Align custom role permission keys, serializers, delete-nullification behavior, and AccountUser permission resolution. | Chatwoot enterprise custom roles policy/model/controller. | Permission matrix tests and handler tests for admin/non-admin access. | Todo | | B10.4 | Review legacy InboxLimit routes versus Chatwoot account-level limit behavior and enforce create limits in inbox/channel creation paths. | Chatwoot enterprise inbox limit behavior, existing capacity-limit implementation. | Inbox/channel creation tests cover under-limit and over-limit frontend-readable errors. | Todo | @@ -655,6 +655,24 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` +B10.2b current checkpoint: + +- Inbox create/update now write `Inbox` audit rows through the shared writer, matching Chatwoot enterprise `audited associated_with: :account, on: [:create, :update]` for inboxes. +- Conversation update/delete/agent assignment/status toggle now write `Conversation` audit rows, covering the tracker's representative assignment/status/delete paths while keeping Chatwoot's destroy-audit concern visible. +- SLA policy create/update/delete now write `SlaPolicy` audit rows with serialized threshold/name fields. +- AgentCapacityPolicy create/update/delete now write `AgentCapacityPolicy` audit rows; nested inbox capacity limit create/update/delete and policy user assignment/removal write `InboxCapacityLimit` and `AgentCapacityPolicyUser` rows. +- B10.2a and B10.2b together cover the full named B10.2 representative mutation set: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, and CSAT review notes. + +B10.2b verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'SlaPolicy|AgentCapacity|Inbox|Conversation' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run Audit -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +git diff --check +``` + B11 Captain/Copilot breakdown: | Step | Implementation target | Reference source | Required tests | Status | @@ -1047,7 +1065,7 @@ Enterprise tracking table: | ID | Feature | Existing Go surface | Required next work | Status | | --- | --- | --- | --- | --- | | P4.1 | SLA policies/events | `internal/model/sla_policy.go`, `internal/model/sla_event.go`, `internal/service/sla_policy_service.go`, `internal/service/applied_sla_service.go`, `internal/service/sla_event_service.go`, `internal/handler/api/v1/sla_policy_handler.go` | SLA policy CRUD, applied-SLA conversation attach, idempotent core breach lifecycle, applied-SLA reports, current reference timing review, and SLA miss notifications are implemented; durable job scheduling remains Phase 5. | Review | -| P4.2 | Audit logs | `internal/model/audit.go`, `internal/service/audit_service.go`, `internal/repository/audit_repo.go`, `internal/handler/api/v1/audit_handler.go` | Audit list payload, admin access, associated-account scoping, fixed pagination, shared audit writer boundary, and automation/macro/custom-role/CSAT review-note writer coverage are implemented; next cover inbox/conversation/SLA/capacity mutations. | Doing | +| P4.2 | Audit logs | `internal/model/audit.go`, `internal/service/audit_service.go`, `internal/repository/audit_repo.go`, `internal/handler/api/v1/audit_handler.go` | Audit list payload, admin access, associated-account scoping, fixed pagination, shared audit writer boundary, and named representative writer coverage are implemented; deeper resource-specific `auditable.push_event_data` remains future serializer depth if frontend requires it. | Review | | P4.3 | Custom roles/permissions | `internal/model/custom_role.go`, `internal/service/custom_role_service.go`, `internal/middleware/role_check.go`, `internal/handler/api/v1/custom_role_handler.go` | Align permission keys, inherited roles, authorization failures, and admin UX payloads. | Todo | | P4.4 | Agent capacity | `internal/model/agent_capacity_policy.go`, `internal/service/agent_capacity_policy_service.go`, `internal/handler/api/v1/agent_capacity_handler.go`, `internal/autoassignment/*` | First align AgentCapacityPolicy API, serializers, nested users, and `InboxCapacityLimit` data contracts; then enforce capacity in manual/auto assignment. | Review | | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits` and assignment-path enforcement are covered; next review account-level create-limit behavior and legacy route compatibility. | Review | @@ -1065,7 +1083,7 @@ Enterprise work package breakdown: | CSAT account/public side | Survey send on resolve, response list, metrics, filters, downloads, review notes, resend/idempotency, public lock. | Account report list/metrics/review-note payload fixtures are covered by `f441680`; public object-shaped submit/update, one-response-per-message idempotency, and resolve-triggered generic survey message send are covered by `ef3a909`; download CSV is covered by `b36cf07`. | Review | | Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook and transcript actions. | B9.1a CRUD, B9.1b listener coverage, B9.1c execution outcome logging, and B9.1d retryable webhook/email transcript delivery are covered. Durable delayed actions remain B9.3/Phase 5. | Review | | Macros | Macro CRUD, availability by account/user, execute side effects, validation, audit/log output. | CRUD payloads, visibility/authorization, display-ID execution, labels/status/messages/private notes, and empty execute/delete responses are covered by `feat(macros): align chatwoot macro payloads`; deeper attachment files and durable queue remain follow-ups. | Review | -| Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; automation/macro/custom-role/CSAT review-note writer coverage is covered by `feat(audit): record enterprise mutations`; next representative inbox/conversation/SLA/capacity mutations emit audit records. | Doing | +| Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission matrix tests and frontend admin payload fixtures. | Todo | | Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API and assignment-path enforcement are covered; account-level create limits remain for later review. | Review | | Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, copilot threads/messages, tasks, streaming/tool-call behavior. | Route fixtures, persistence tests, feature gates for external LLM dependencies, frontend smoke screens. | Todo | @@ -1298,3 +1316,4 @@ Verification milestone gates: - 2026-06-05: B9.2 macro payload/execute checkpoint prepared as `feat(macros): align chatwoot macro payloads`; macro CRUD now returns Chatwoot `{ payload }` serializers with string visibility and nested agent authors, accepts frontend array action params, enforces `MacroPolicy`-style public/personal access, deletes and executes with empty `200 OK`, and resolves execute `conversation_ids` by display ID before applying labels/status/messages/private notes. Focused macro handler/service tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B10 audit/custom roles/remaining limits while B9.3/B9.4 worker and attachment depth remain tracked follow-ups. - 2026-06-05: B10.1 audit list checkpoint prepared as `feat(audit): align chatwoot audit log payloads`; audit logs now return the enterprise Jbuilder top-level payload consumed by the reused settings screen, use fixed 25-row pagination, enforce administrator/super_admin access, scope through Chatwoot associated audits or local account IDs, and emit actor/request/change fields with Unix timestamps. Focused audit handler/repository/service tests, handler package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B10.2 audit writer coverage, then B10.3 CustomRole permission parity. - 2026-06-05: B10.2a audit writer checkpoint prepared as `feat(audit): record enterprise mutations`; a shared `AuditService.Record` boundary now writes account-associated audit rows with actor/request metadata and JSON changes, and automation-rule, macro, custom-role, and CSAT review-note mutations call it. Focused CustomRole/AutomationRule/Macro/CsatSurvey handler tests, audit service tests, handler package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B10.2b inbox/conversation/SLA/capacity writer coverage, then B10.3 CustomRole permission parity. +- 2026-06-05: B10.2b operational audit checkpoint prepared as `feat(audit): cover operational mutations`; inbox create/update, conversation update/delete/assignment/status, SLA policy CRUD, AgentCapacityPolicy CRUD, nested inbox capacity limits, and capacity-policy users now call the shared audit writer. Focused SLA/capacity/inbox/conversation handler tests, audit service tests, handler package tests, full `go test ./...`, and `git diff --check` passed. B10.2 moves to Review; next slice is B10.3 CustomRole permission parity. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 4b250605..e1a2e51f 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -693,8 +693,8 @@ func Bootstrap(env string) (*App, error) { SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService), - Conversation: v1.NewConversationHandler(conversationService, messageService), - Inbox: v1.NewInboxHandler(inboxService), + Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService), + Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), InboxMember: v1.NewInboxMemberHandler(inboxMemberService), WebWidget: v1.NewWebWidgetHandler(inboxService), WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService), @@ -750,12 +750,12 @@ func Bootstrap(env string) (*App, error) { Label: v1.NewLabelHandler(tagService, labelService), Campaign: v1.NewCampaignHandler(campaignService), AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService), - SlaPolicy: v1.NewSlaPolicyHandler(slaPolicyService), + SlaPolicy: v1.NewSlaPolicyHandler(slaPolicyService).WithAuditService(auditService), AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service), // Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics handlers Audit: v1.NewAuditHandler(auditService), CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService), - AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService), + AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService).WithAuditService(auditService), CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService), Search: v1.NewSearchHandler(searchService), Widget: widgetHandler, diff --git a/internal/handler/api/v1/agent_capacity_handler.go b/internal/handler/api/v1/agent_capacity_handler.go index cc7a9221..877928b3 100644 --- a/internal/handler/api/v1/agent_capacity_handler.go +++ b/internal/handler/api/v1/agent_capacity_handler.go @@ -16,7 +16,8 @@ import ( // AgentCapacityHandler handles AgentCapacityPolicy CRUD operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/agent_capacity_policies_controller.rb type AgentCapacityHandler struct { - svc *service.AgentCapacityPolicyService + svc *service.AgentCapacityPolicyService + auditSvc *service.AuditService } // NewAgentCapacityHandler creates a new AgentCapacityPolicy handler. @@ -24,6 +25,11 @@ func NewAgentCapacityHandler(svc *service.AgentCapacityPolicyService) *AgentCapa return &AgentCapacityHandler{svc: svc} } +func (h *AgentCapacityHandler) WithAuditService(auditSvc *service.AuditService) *AgentCapacityHandler { + h.auditSvc = auditSvc + return h +} + // List returns all agent capacity policies for an account. // GET /api/v1/accounts/:account_id/agent_capacity_policies func (h *AgentCapacityHandler) List(c *gin.Context) { @@ -69,6 +75,13 @@ func (h *AgentCapacityHandler) Create(c *gin.Context) { handleAgentCapacityError(c, err) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AgentCapacityPolicy", + AuditableID: policy.ID, + Action: "create", + AuditedChanges: serializeAgentCapacityPolicy(policy), + }) c.JSON(http.StatusOK, serializeAgentCapacityPolicy(policy)) } @@ -125,6 +138,13 @@ func (h *AgentCapacityHandler) Update(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AgentCapacityPolicy", + AuditableID: policy.ID, + Action: "update", + AuditedChanges: serializeAgentCapacityPolicy(policy), + }) c.JSON(http.StatusOK, serializeAgentCapacityPolicy(policy)) } @@ -149,6 +169,13 @@ func (h *AgentCapacityHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AgentCapacityPolicy", + AuditableID: id, + Action: "destroy", + AuditedChanges: gin.H{"id": id}, + }) c.Status(http.StatusOK) } @@ -174,6 +201,13 @@ func (h *AgentCapacityHandler) CreateInboxLimit(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "InboxCapacityLimit", + AuditableID: limit.ID, + Action: "create", + AuditedChanges: serializeInboxCapacityLimit(limit, false), + }) c.JSON(http.StatusOK, serializeInboxCapacityLimit(limit, false)) } @@ -203,6 +237,13 @@ func (h *AgentCapacityHandler) UpdateInboxLimit(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "InboxCapacityLimit", + AuditableID: limit.ID, + Action: "update", + AuditedChanges: serializeInboxCapacityLimit(limit, true), + }) c.JSON(http.StatusOK, serializeInboxCapacityLimit(limit, true)) } @@ -226,6 +267,13 @@ func (h *AgentCapacityHandler) DeleteInboxLimit(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "InboxCapacityLimit", + AuditableID: limitID, + Action: "destroy", + AuditedChanges: gin.H{"id": limitID, "agent_capacity_policy_id": policyID}, + }) c.Status(http.StatusNoContent) } @@ -273,6 +321,16 @@ func (h *AgentCapacityHandler) CreateUser(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AgentCapacityPolicyUser", + AuditableID: user.ID, + Action: "create", + AuditedChanges: gin.H{ + "agent_capacity_policy_id": policyID, + "user_id": user.ID, + }, + }) c.JSON(http.StatusOK, serializeAgentCapacityUser(user, accountID)) } @@ -296,6 +354,16 @@ func (h *AgentCapacityHandler) DeleteUser(c *gin.Context) { handleAgentCapacityError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AgentCapacityPolicyUser", + AuditableID: userID, + Action: "destroy", + AuditedChanges: gin.H{ + "agent_capacity_policy_id": policyID, + "user_id": userID, + }, + }) c.Status(http.StatusOK) } diff --git a/internal/handler/api/v1/agent_capacity_handler_test.go b/internal/handler/api/v1/agent_capacity_handler_test.go index 87731833..71d54cbe 100644 --- a/internal/handler/api/v1/agent_capacity_handler_test.go +++ b/internal/handler/api/v1/agent_capacity_handler_test.go @@ -33,12 +33,13 @@ func (s *AgentCapacityHandlerTestSuite) SetupSuite() { Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) - s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{})) + s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, &model.Audit{})) s.db = db repo := repository.NewAgentCapacityPolicyRepo(db) svc := service.NewAgentCapacityPolicyService(repo) - s.handler = NewAgentCapacityHandler(svc) + auditSvc := service.NewAuditService(repository.NewAuditRepo(db)) + s.handler = NewAgentCapacityHandler(svc).WithAuditService(auditSvc) s.account = &model.Account{Name: "test-capacity-account"} s.Require().NoError(db.Create(s.account).Error) @@ -55,6 +56,10 @@ func TestAgentCapacityHandlerSuite(t *testing.T) { suite.Run(t, new(AgentCapacityHandlerTestSuite)) } +func (s *AgentCapacityHandlerTestSuite) SetupTest() { + s.Require().NoError(s.db.Exec("DELETE FROM audits").Error) +} + func (s *AgentCapacityHandlerTestSuite) TestList_Success() { r := gin.New() r.GET("/api/v1/accounts/:account_id/agent_capacity_policies", s.handler.List) @@ -208,3 +213,54 @@ func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserFlow( r.ServeHTTP(w, req) s.Require().Equal(http.StatusNoContent, w.Code) } + +func (s *AgentCapacityHandlerTestSuite) TestMutations_WriteAuditEntries() { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("account_id", s.account.ID) + c.Set("user_id", uint(99)) + c.Next() + }) + api := r.Group("/api/v1/accounts/:account_id") + RegisterAgentCapacityRoutes(api, s.handler) + + w := httptest.NewRecorder() + body := `{"agent_capacity_policy":{"name":"Audit capacity","description":"tracked"}}` + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/", s.account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "capacity-audit-create") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + var policy map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &policy)) + policyID := uint(policy["id"].(float64)) + + w = httptest.NewRecorder() + body = `{"agent_capacity_policy":{"name":"Audit capacity updated","description":"tracked again"}}` + req, _ = http.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d", s.account.ID, policyID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d", s.account.ID, policyID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + + var audits []model.Audit + s.Require().NoError(s.db.Order("id ASC").Find(&audits).Error) + s.Require().Len(audits, 3) + for _, audit := range audits { + s.Equal(s.account.ID, *audit.AccountID) + s.Equal("Account", audit.AssociatedType) + s.Equal(s.account.ID, *audit.AssociatedID) + s.Equal(uint(99), *audit.UserID) + s.Equal("AgentCapacityPolicy", audit.AuditableType) + s.Equal(policyID, audit.AuditableID) + s.NotEmpty(audit.AuditedChanges) + } + s.Equal("create", audits[0].Action) + s.Equal("capacity-audit-create", audits[0].RequestUUID) + s.Equal("update", audits[1].Action) + s.Equal("destroy", audits[2].Action) +} diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index 59986eb0..7cd634f7 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -21,6 +21,7 @@ import ( type ConversationHandler struct { conversationSvc *service.ConversationService messageSvc *service.MessageService + auditSvc *service.AuditService } // NewConversationHandler creates a new ConversationHandler. @@ -28,6 +29,11 @@ func NewConversationHandler(conversationSvc *service.ConversationService, messag return &ConversationHandler{conversationSvc: conversationSvc, messageSvc: messageSvc} } +func (h *ConversationHandler) WithAuditService(auditSvc *service.AuditService) *ConversationHandler { + h.auditSvc = auditSvc + return h +} + // @Summary List conversations for an account // @Description Retrieves all conversations for an account with pagination, optionally filtered by status query param // @Tags Conversations @@ -99,6 +105,13 @@ func (h *ConversationHandler) Create(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Conversation", + AuditableID: conversation.ID, + Action: "update", + AuditedChanges: gin.H{"status": conversation.Status, "priority": conversation.Priority, "sla_policy_id": conversation.SlaPolicyID}, + }) c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) } @@ -193,6 +206,13 @@ func (h *ConversationHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Conversation", + AuditableID: conversation.ID, + Action: "destroy", + AuditedChanges: gin.H{"id": conversation.ID, "display_id": conversation.DisplayID}, + }) response.NoContent(c) } @@ -238,6 +258,13 @@ func (h *ConversationHandler) AssignAgent(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Conversation", + AuditableID: conversation.ID, + Action: "update", + AuditedChanges: gin.H{"assignee_id": conversation.AssigneeID}, + }) c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), req.AssigneeID, accountID)) } @@ -286,6 +313,13 @@ func (h *ConversationHandler) ToggleStatus(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Conversation", + AuditableID: conversation.ID, + Action: "update", + AuditedChanges: gin.H{"status": conversation.Status, "snoozed_until": conversation.SnoozedUntil}, + }) c.JSON(http.StatusOK, gin.H{"meta": gin.H{}, "payload": gin.H{ "success": true, "conversation_id": conversationDisplayID(conversation), diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index 7e6232f9..5b5b1ed6 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -16,7 +16,8 @@ import ( // InboxHandler handles inbox-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb type InboxHandler struct { - svc *service.InboxService + svc *service.InboxService + auditSvc *service.AuditService } // NewInboxHandler creates a new InboxHandler. @@ -24,6 +25,11 @@ func NewInboxHandler(svc *service.InboxService) *InboxHandler { return &InboxHandler{svc: svc} } +func (h *InboxHandler) WithAuditService(auditSvc *service.AuditService) *InboxHandler { + h.auditSvc = auditSvc + return h +} + // @Summary List inboxes for an account // @Description Retrieves all inboxes for an account with pagination // @Tags Inboxes @@ -131,6 +137,13 @@ func (h *InboxHandler) Create(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"}) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Inbox", + AuditableID: inbox.ID, + Action: "create", + AuditedChanges: serializeInbox(inbox), + }) c.JSON(http.StatusOK, serializeInbox(inbox)) } @@ -177,6 +190,13 @@ func (h *InboxHandler) Update(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update inbox"}) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Inbox", + AuditableID: inbox.ID, + Action: "update", + AuditedChanges: serializeInbox(inbox), + }) c.JSON(http.StatusOK, serializeInbox(inbox)) } diff --git a/internal/handler/api/v1/sla_policy_handler.go b/internal/handler/api/v1/sla_policy_handler.go index e347e8fa..4bd4f28a 100644 --- a/internal/handler/api/v1/sla_policy_handler.go +++ b/internal/handler/api/v1/sla_policy_handler.go @@ -21,7 +21,8 @@ import ( // SlaPolicyHandler handles SLA Policy CRUD + applied SLA metrics/download. // Reference: Chatwoot app/controllers/api/v1/sla_policies_controller.rb type SlaPolicyHandler struct { - svc *service.SlaPolicyService + svc *service.SlaPolicyService + auditSvc *service.AuditService } // NewSlaPolicyHandler creates a new SlaPolicy handler. @@ -29,6 +30,11 @@ func NewSlaPolicyHandler(svc *service.SlaPolicyService) *SlaPolicyHandler { return &SlaPolicyHandler{svc: svc} } +func (h *SlaPolicyHandler) WithAuditService(auditSvc *service.AuditService) *SlaPolicyHandler { + h.auditSvc = auditSvc + return h +} + // Create creates a new SLA policy. // POST /api/v1/accounts/:account_id/sla_policies func (h *SlaPolicyHandler) Create(c *gin.Context) { @@ -51,6 +57,13 @@ func (h *SlaPolicyHandler) Create(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "SlaPolicy", + AuditableID: policy.ID, + Action: "create", + AuditedChanges: serializeSlaPolicy(policy), + }) c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicy(policy)}) } @@ -127,6 +140,13 @@ func (h *SlaPolicyHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "SlaPolicy", + AuditableID: policy.ID, + Action: "update", + AuditedChanges: serializeSlaPolicy(policy), + }) c.JSON(http.StatusOK, gin.H{"payload": serializeSlaPolicy(policy)}) } @@ -152,6 +172,13 @@ func (h *SlaPolicyHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "SlaPolicy", + AuditableID: policyID, + Action: "destroy", + AuditedChanges: gin.H{"id": policyID}, + }) // Chatwoot returns head :ok (200) on destroy. c.Status(http.StatusOK) diff --git a/internal/handler/api/v1/sla_policy_handler_test.go b/internal/handler/api/v1/sla_policy_handler_test.go index bc39c6c3..b3cb4954 100644 --- a/internal/handler/api/v1/sla_policy_handler_test.go +++ b/internal/handler/api/v1/sla_policy_handler_test.go @@ -41,6 +41,7 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) { &model.Conversation{}, &model.User{}, &model.Team{}, + &model.Audit{}, )) t.Cleanup(func() { sqlDB, _ := db.DB() @@ -52,7 +53,8 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) { slaEventRepo := repository.NewSlaEventRepo(db) slaPolicyInboxRepo := repository.NewSlaPolicyInboxRepo(db) svc := service.NewSlaPolicyService(slaPolicyRepo, appliedSlaRepo, slaEventRepo, slaPolicyInboxRepo) - handler := NewSlaPolicyHandler(svc) + auditSvc := service.NewAuditService(repository.NewAuditRepo(db)) + handler := NewSlaPolicyHandler(svc).WithAuditService(auditSvc) // Seed an account for all tests account := &model.Account{Name: "SlaHandlerOrg", Locale: "en", Active: true} @@ -101,6 +103,21 @@ func setupSlaPolicyTestRouter(handler *SlaPolicyHandler) *gin.Engine { return r } +func setupSlaPolicyAuditRouter(handler *SlaPolicyHandler, accountID uint, userID uint) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("account_id", accountID) + c.Set("user_id", userID) + c.Next() + }) + rg := r.Group("/api/v1/accounts/:account_id") + rg.POST("/sla_policies", handler.Create) + rg.PUT("/sla_policies/:id", handler.Update) + rg.DELETE("/sla_policies/:id", handler.Delete) + return r +} + // ========== List ========== func TestSlaPolicyHandler_List_Success(t *testing.T) { @@ -211,6 +228,62 @@ func TestSlaPolicyHandler_Create_ValidationError(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestSlaPolicyHandler_MutationsWriteAuditEntries(t *testing.T) { + handler, db := setupSlaPolicyHandlerTest(t) + accountID := slaHandlerAccountIDUint(db) + router := setupSlaPolicyAuditRouter(handler, accountID, 88) + + createBody, _ := json.Marshal(map[string]any{"sla_policy": map[string]any{ + "name": "Audit SLA", + "first_response_time_threshold": 15, + "next_response_time_threshold": 30, + "resolution_time_threshold": 90, + }}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/sla_policies", bytes.NewBuffer(createBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "sla-audit-create") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var created map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + policyID := uint(created["payload"].(map[string]any)["id"].(float64)) + + updateBody, _ := json.Marshal(map[string]any{"sla_policy": map[string]any{ + "name": "Audit SLA Updated", + "first_response_time_threshold": 20, + "next_response_time_threshold": 40, + "resolution_time_threshold": 100, + }}) + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/sla_policies/"+strconv.FormatUint(uint64(policyID), 10), bytes.NewBuffer(updateBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/sla_policies/"+strconv.FormatUint(uint64(policyID), 10), nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var audits []model.Audit + require.NoError(t, db.Order("id ASC").Find(&audits).Error) + require.Len(t, audits, 3) + for _, audit := range audits { + assert.Equal(t, accountID, *audit.AccountID) + assert.Equal(t, "Account", audit.AssociatedType) + assert.Equal(t, accountID, *audit.AssociatedID) + assert.Equal(t, uint(88), *audit.UserID) + assert.Equal(t, "SlaPolicy", audit.AuditableType) + assert.Equal(t, policyID, audit.AuditableID) + assert.NotEmpty(t, audit.AuditedChanges) + } + assert.Equal(t, "create", audits[0].Action) + assert.Equal(t, "sla-audit-create", audits[0].RequestUUID) + assert.Equal(t, "update", audits[1].Action) + assert.Equal(t, "destroy", audits[2].Action) +} + func TestSlaPolicyHandler_Create_NoAccountID(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New()