diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 840fc315..1a1810b5 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): align chatwoot audit log payloads`. -- Latest documentation checkpoint: this checkpoint, recorded with the B10.1 audit log implementation. -- Worktree status at this implementation checkpoint: B10.1 audit log list payloads, admin gate, account scoping, fixed pagination, and serializer fields are implemented; next active slice is B10.2 audit writers and B10.3 CustomRole permission parity. +- 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. - `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,10 +44,11 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B10.2/B10.3 | Audit writers and CustomRole permission behavior enforce Chatwoot admin behavior. | Representative mutation audit tests plus CustomRole permission matrix 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/`. | +| 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/`. | ## Execution Snapshot @@ -142,12 +143,13 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(automation): deliver retryable external actions` | Completed B9.1d external action delivery: automation webhook actions now build Chatwoot-style `automation_event.` payloads and deliver through a timeout-bound 3-attempt HTTP boundary; email transcript actions now split comma recipients, build transcript subject/body, deliver through a fakeable SMTP/mailer boundary, and both action types write delivery metadata into `action_results`. Chatwoot array params now round-trip `send_webhook_event` URLs. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.2 macro CRUD and macro execute side-effect parity; durable queued worker scheduling remains B9.3/Phase 5. | | `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. | ## Next Slice Contract -Completed implementation slice: B10.1 now covers the enterprise audit log list/get API shape consumed by the reused Chatwoot settings screen, including admin-only access, account-associated scope, fixed 25-row pagination, and Jbuilder-compatible field names. +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. -Next implementation slice: continue B10.2 audit writer coverage for representative core/enterprise 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.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. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -392,8 +394,9 @@ 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 | 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.3 | 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 | +| 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.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 | | B11 | B11.2 | Align Copilot threads, messages, tasks, preferences, playground/tool-call behavior, and disabled-state feature gates. | Copilot controllers/services/frontend clients under `reference/chatwoot`. | Copilot handler/service tests for persistence, disabled LLM state, and frontend payloads. | Todo | | B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required. | Captain/Copilot jobs and document services. | Worker tests or explicit feature-gated fallback tests. | Todo | @@ -608,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. | Todo | +| 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.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 | @@ -632,6 +635,26 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` +B10.2a current checkpoint: + +- `AuditService.Record` is now the shared audit writer boundary for account-associated mutations. It records `account_id`, `associated_type: Account`, `associated_id`, `auditable_type`, `auditable_id`, `action`, JSON `audited_changes`, optional `user_id/user_type`, request UUID, remote address, and comment. +- The handler-level audit writer is non-blocking for HTTP responses but logs skipped audit writes, keeping product mutations available while still making missing audit rows visible in tests/logs. +- Automation rule create/update/delete/clone/toggle now write `AutomationRule` audit rows, matching the enterprise audit concern that audits automation rules associated with the account. +- Macro create/update/delete now write `Macro` audit rows, matching the enterprise macro audit concern. +- CustomRole create/update/delete now write `CustomRole` audit rows for this Go enterprise scope. Chatwoot custom roles are still tracked for deeper B10.3 permission and deletion-nullification parity. +- CSAT review-note update now writes a `CsatSurveyResponse` update audit row, covering the enterprise reporting mutation exposed in the reused reports screen. +- Inbox/channel, conversation assignment/status, SLA policy, and AgentCapacityPolicy writer coverage remain explicit B10.2b work rather than being hidden under this boundary checkpoint. + +B10.2a verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'CustomRole|AutomationRule|Macro|CsatSurvey' -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 | @@ -1024,7 +1047,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, and fixed pagination match Chatwoot; next ensure every representative mutating enterprise/core action emits audit events. | 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 automation/macro/custom-role/CSAT review-note writer coverage are implemented; next cover inbox/conversation/SLA/capacity mutations. | Doing | | 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 | @@ -1042,7 +1065,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`; next representative mutations across core and enterprise features 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`; 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 | | 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 | @@ -1274,3 +1297,4 @@ Verification milestone gates: - 2026-06-05: B9.1d external action delivery checkpoint prepared as `feat(automation): deliver retryable external actions`; `send_webhook_event` now builds Chatwoot-style automation webhook payloads and delivers through a timeout-bound retryable HTTP boundary, `send_email_transcript` parses recipients and sends generated transcript emails through a fakeable SMTP boundary, and automation execution logs now record delivery type, target, attempts, response code/body, retryability, and failure errors. Focused automation tests, automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.2 macro CRUD and execution parity. - 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. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 8f70cf01..4b250605 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -733,9 +733,9 @@ func Bootstrap(env string) (*App, error) { Article: v1.NewArticleHandler(articleService), Folder: v1.NewFolderHandler(folderService), PortalMember: v1.NewPortalMemberHandler(portalMemberService), - AutomationRule: v1.NewAutomationRuleHandler(automationRuleService), - Macro: v1.NewMacroHandler(macroService), - CsatSurvey: v1.NewCsatSurveyHandler(csatSurveyService), + AutomationRule: v1.NewAutomationRuleHandler(automationRuleService).WithAuditService(auditService), + Macro: v1.NewMacroHandler(macroService).WithAuditService(auditService), + CsatSurvey: v1.NewCsatSurveyHandler(csatSurveyService).WithAuditService(auditService), CannedResponse: v1.NewCannedResponseHandler(cannedResponseService), PushSubscription: v1.NewPushSubscriptionHandler(pushSubscriptionService), NotificationSubscription: v1.NewNotificationSubscriptionHandler(notificationSubscriptionService), @@ -754,7 +754,7 @@ func Bootstrap(env string) (*App, error) { AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service), // Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics handlers Audit: v1.NewAuditHandler(auditService), - CustomRole: v1.NewCustomRoleHandler(customRoleService), + CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService), AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService), CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService), Search: v1.NewSearchHandler(searchService), diff --git a/internal/handler/api/v1/audit_writer.go b/internal/handler/api/v1/audit_writer.go new file mode 100644 index 00000000..b24e05d4 --- /dev/null +++ b/internal/handler/api/v1/audit_writer.go @@ -0,0 +1,46 @@ +package v1 + +import ( + "github.com/gin-gonic/gin" + + "github.com/gochat/gochat/internal/service" + applogger "github.com/gochat/gochat/pkg/logger" +) + +type auditMutation struct { + AccountID uint + AuditableType string + AuditableID uint + Action string + AuditedChanges interface{} + Comment string +} + +func recordAuditMutation(c *gin.Context, auditSvc *service.AuditService, mutation auditMutation) { + if auditSvc == nil { + return + } + _, err := auditSvc.Record(c.Request.Context(), service.AuditRecord{ + AccountID: mutation.AccountID, + UserID: getUserID(c), + AuditableType: mutation.AuditableType, + AuditableID: mutation.AuditableID, + Action: mutation.Action, + AuditedChanges: mutation.AuditedChanges, + RemoteAddress: c.ClientIP(), + RequestUUID: firstNonEmpty(c.GetHeader("X-Request-ID"), c.GetHeader("X-Correlation-ID")), + Comment: mutation.Comment, + }) + if err != nil { + applogger.L().Warnf("audit mutation skipped type=%s id=%d action=%s: %v", mutation.AuditableType, mutation.AuditableID, mutation.Action, err) + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/handler/api/v1/automation_rule_handler.go b/internal/handler/api/v1/automation_rule_handler.go index ecb24f32..84d20442 100644 --- a/internal/handler/api/v1/automation_rule_handler.go +++ b/internal/handler/api/v1/automation_rule_handler.go @@ -6,13 +6,15 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" + "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) // AutomationRuleHandler handles automation rule API endpoints. // Reference: Chatwoot AutomationRulesController — CRUD + clone type AutomationRuleHandler struct { - svc *automation.AutomationRuleService + svc *automation.AutomationRuleService + auditSvc *service.AuditService } // NewAutomationRuleHandler creates a new AutomationRuleHandler. @@ -20,6 +22,11 @@ func NewAutomationRuleHandler(svc *automation.AutomationRuleService) *Automation return &AutomationRuleHandler{svc: svc} } +func (h *AutomationRuleHandler) WithAuditService(auditSvc *service.AuditService) *AutomationRuleHandler { + h.auditSvc = auditSvc + return h +} + type automationRuleRequest struct { Name string `json:"name"` Description string `json:"description"` @@ -116,6 +123,13 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AutomationRule", + AuditableID: rule.ID, + Action: "create", + AuditedChanges: serializeAutomationRule(rule), + }) c.JSON(http.StatusOK, serializeAutomationRule(rule)) } @@ -162,6 +176,13 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AutomationRule", + AuditableID: updated.ID, + Action: "update", + AuditedChanges: serializeAutomationRule(updated), + }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(updated)}) } @@ -184,6 +205,13 @@ func (h *AutomationRuleHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AutomationRule", + AuditableID: automationID, + Action: "destroy", + AuditedChanges: gin.H{"id": automationID}, + }) c.Status(http.StatusOK) } @@ -208,6 +236,14 @@ func (h *AutomationRuleHandler) Clone(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AutomationRule", + AuditableID: cloned.ID, + Action: "create", + AuditedChanges: serializeAutomationRule(cloned), + Comment: "cloned from automation rule", + }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(cloned)}) } @@ -250,6 +286,13 @@ func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "AutomationRule", + AuditableID: rule.ID, + Action: "update", + AuditedChanges: gin.H{"active": rule.Active}, + }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)}) } diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index c3d29885..03b1e388 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -13,13 +13,15 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) // CsatSurveyHandler handles CSAT survey API endpoints. // Reference: Chatwoot CsatSurveyResponsesController — list, metrics, review notes, public access type CsatSurveyHandler struct { - svc *automation.CsatSurveyService + svc *automation.CsatSurveyService + auditSvc *service.AuditService } // NewCsatSurveyHandler creates a new CsatSurveyHandler. @@ -27,6 +29,11 @@ func NewCsatSurveyHandler(svc *automation.CsatSurveyService) *CsatSurveyHandler return &CsatSurveyHandler{svc: svc} } +func (h *CsatSurveyHandler) WithAuditService(auditSvc *service.AuditService) *CsatSurveyHandler { + h.auditSvc = auditSvc + return h +} + // List retrieves CSAT survey responses for an account with optional filters. // GET /api/v1/accounts/:account_id/csat_survey_responses func (h *CsatSurveyHandler) List(c *gin.Context) { @@ -106,6 +113,13 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: resp.AccountID, + AuditableType: "CsatSurveyResponse", + AuditableID: resp.ID, + Action: "update", + AuditedChanges: gin.H{"csat_review_notes": notes}, + }) c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } diff --git a/internal/handler/api/v1/custom_role_handler.go b/internal/handler/api/v1/custom_role_handler.go index 2dd3080e..e349028f 100644 --- a/internal/handler/api/v1/custom_role_handler.go +++ b/internal/handler/api/v1/custom_role_handler.go @@ -14,7 +14,8 @@ import ( // CustomRoleHandler handles CustomRole CRUD operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/custom_roles_controller.rb type CustomRoleHandler struct { - svc *service.CustomRoleService + svc *service.CustomRoleService + auditSvc *service.AuditService } // NewCustomRoleHandler creates a new CustomRole handler. @@ -22,6 +23,11 @@ func NewCustomRoleHandler(svc *service.CustomRoleService) *CustomRoleHandler { return &CustomRoleHandler{svc: svc} } +func (h *CustomRoleHandler) WithAuditService(auditSvc *service.AuditService) *CustomRoleHandler { + h.auditSvc = auditSvc + return h +} + // List returns all custom roles for an account. // GET /api/v1/accounts/:account_id/custom_roles func (h *CustomRoleHandler) List(c *gin.Context) { @@ -66,6 +72,13 @@ func (h *CustomRoleHandler) Create(c *gin.Context) { handleServiceError(c, err) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "CustomRole", + AuditableID: role.ID, + Action: "create", + AuditedChanges: role, + }) response.Created(c, role) } @@ -125,6 +138,13 @@ func (h *CustomRoleHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "CustomRole", + AuditableID: role.ID, + Action: "update", + AuditedChanges: role, + }) response.OK(c, role) } @@ -149,6 +169,13 @@ func (h *CustomRoleHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "CustomRole", + AuditableID: id, + Action: "destroy", + AuditedChanges: gin.H{"id": id}, + }) response.NoContent(c) } @@ -163,4 +190,4 @@ func RegisterCustomRoleRoutes(rg *gin.RouterGroup, h *CustomRoleHandler) { customRoles.PUT("/:id", h.Update) customRoles.DELETE("/:id", h.Delete) } -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/custom_role_handler_test.go b/internal/handler/api/v1/custom_role_handler_test.go index 80226214..2edaef00 100644 --- a/internal/handler/api/v1/custom_role_handler_test.go +++ b/internal/handler/api/v1/custom_role_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -31,12 +32,13 @@ func (s *CustomRoleHandlerTestSuite) SetupSuite() { Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) - s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.CustomRole{})) + s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.CustomRole{}, &model.Audit{})) s.db = db repo := repository.NewCustomRoleRepo(db) svc := service.NewCustomRoleService(repo) - s.handler = NewCustomRoleHandler(svc) + auditSvc := service.NewAuditService(repository.NewAuditRepo(db)) + s.handler = NewCustomRoleHandler(svc).WithAuditService(auditSvc) s.account = &model.Account{Name: "test-custom-role-account"} s.Require().NoError(db.Create(s.account).Error) @@ -53,6 +55,11 @@ func TestCustomRoleHandlerSuite(t *testing.T) { suite.Run(t, new(CustomRoleHandlerTestSuite)) } +func (s *CustomRoleHandlerTestSuite) SetupTest() { + s.Require().NoError(s.db.Exec("DELETE FROM audits").Error) + s.Require().NoError(s.db.Exec("DELETE FROM custom_roles").Error) +} + func (s *CustomRoleHandlerTestSuite) TestList_Success() { r := gin.New() r.GET("/api/v1/accounts/:account_id/custom_roles", s.handler.List) @@ -175,4 +182,64 @@ func (s *CustomRoleHandlerTestSuite) TestDelete_Success() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNoContent, w.Code) -} \ No newline at end of file +} + +func (s *CustomRoleHandlerTestSuite) TestMutations_WriteAuditEntries() { + r := gin.New() + r.POST("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Create)) + r.PUT("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Update)) + r.DELETE("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Delete)) + + createBody := `{"custom_role":{"name":"audit-role","permissions":{"conversation_manage":"full"}}}` + createReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), bytes.NewBufferString(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("X-Request-ID", "audit-create-req") + createReq.RemoteAddr = "203.0.113.20:1234" + createW := httptest.NewRecorder() + r.ServeHTTP(createW, createReq) + s.Require().Equal(http.StatusCreated, createW.Code) + + var createResp struct { + Data model.CustomRole `json:"data"` + } + s.Require().NoError(json.Unmarshal(createW.Body.Bytes(), &createResp)) + roleID := createResp.Data.ID + + updateBody := `{"custom_role":{"name":"audit-role-updated","permissions":{"contact_manage":"full"}}}` + updateReq, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), bytes.NewBufferString(updateBody)) + updateReq.Header.Set("Content-Type", "application/json") + updateW := httptest.NewRecorder() + r.ServeHTTP(updateW, updateReq) + s.Require().Equal(http.StatusOK, updateW.Code) + + deleteReq, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), nil) + deleteW := httptest.NewRecorder() + r.ServeHTTP(deleteW, deleteReq) + s.Require().Equal(http.StatusNoContent, deleteW.Code) + + 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(77), *audit.UserID) + s.Equal("User", audit.UserType) + s.Equal("CustomRole", audit.AuditableType) + s.Equal(roleID, audit.AuditableID) + s.NotEmpty(audit.AuditedChanges) + } + s.Equal("create", audits[0].Action) + s.Equal("audit-create-req", audits[0].RequestUUID) + s.Equal("update", audits[1].Action) + s.Equal("destroy", audits[2].Action) +} + +func withCustomRoleAuditContext(accountID uint, userID uint, h gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("account_id", accountID) + c.Set("user_id", userID) + h(c) + } +} diff --git a/internal/handler/api/v1/macro_handler.go b/internal/handler/api/v1/macro_handler.go index f714a697..a0cb5db8 100644 --- a/internal/handler/api/v1/macro_handler.go +++ b/internal/handler/api/v1/macro_handler.go @@ -8,13 +8,15 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) // MacroHandler handles macro API endpoints. // Reference: Chatwoot MacrosController — CRUD + execute type MacroHandler struct { - svc *automation.MacroService + svc *automation.MacroService + auditSvc *service.AuditService } // NewMacroHandler creates a new MacroHandler. @@ -22,6 +24,11 @@ func NewMacroHandler(svc *automation.MacroService) *MacroHandler { return &MacroHandler{svc: svc} } +func (h *MacroHandler) WithAuditService(auditSvc *service.AuditService) *MacroHandler { + h.auditSvc = auditSvc + return h +} + // List retrieves all macros for an account, respecting visibility. // GET /api/v1/accounts/:account_id/macros func (h *MacroHandler) List(c *gin.Context) { @@ -101,6 +108,13 @@ func (h *MacroHandler) Create(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Macro", + AuditableID: macro.ID, + Action: "create", + AuditedChanges: serializeMacro(macro), + }) c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)}) } @@ -143,6 +157,13 @@ func (h *MacroHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Macro", + AuditableID: updated.ID, + Action: "update", + AuditedChanges: serializeMacro(updated), + }) c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(updated)}) } @@ -175,6 +196,13 @@ func (h *MacroHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } + recordAuditMutation(c, h.auditSvc, auditMutation{ + AccountID: accountID, + AuditableType: "Macro", + AuditableID: macroID, + Action: "destroy", + AuditedChanges: gin.H{"id": macroID}, + }) c.Status(http.StatusOK) } diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index ca56a72f..db3e0673 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "github.com/gochat/gochat/internal/model" @@ -15,6 +16,20 @@ type AuditService struct { repo *repository.AuditRepo } +// AuditRecord captures the request/user metadata needed to create a Chatwoot-style audit row. +type AuditRecord struct { + AccountID uint + UserID uint + Username string + AuditableType string + AuditableID uint + Action string + AuditedChanges interface{} + RemoteAddress string + RequestUUID string + Comment string +} + // NewAuditService creates a new AuditLog service. func NewAuditService(repo *repository.AuditRepo) *AuditService { return &AuditService{repo: repo} @@ -52,6 +67,40 @@ func (s *AuditService) CreateAudit(ctx context.Context, audit *model.Audit) (*mo return audit, nil } +// Record creates an account-associated audit row for a mutating account resource. +func (s *AuditService) Record(ctx context.Context, record AuditRecord) (*model.Audit, error) { + if record.AccountID == 0 || record.AuditableType == "" || record.AuditableID == 0 || record.Action == "" { + return nil, fmt.Errorf("invalid audit record") + } + + changes, err := marshalAuditChanges(record.AuditedChanges) + if err != nil { + return nil, fmt.Errorf("invalid audited changes: %w", err) + } + + accountID := record.AccountID + audit := &model.Audit{ + AccountID: &accountID, + AuditableType: record.AuditableType, + AuditableID: record.AuditableID, + Action: record.Action, + AuditedChanges: changes, + AssociatedType: "Account", + AssociatedID: &accountID, + Username: record.Username, + RemoteAddress: record.RemoteAddress, + RequestUUID: record.RequestUUID, + Comment: record.Comment, + } + if record.UserID != 0 { + userID := record.UserID + audit.UserID = &userID + audit.UserType = "User" + } + + return s.CreateAudit(ctx, audit) +} + // GetByID retrieves a single audit log entry by ID. func (s *AuditService) GetByID(ctx context.Context, id uint) (*model.Audit, error) { audit, err := s.repo.FindByID(ctx, id) @@ -71,3 +120,20 @@ func (s *AuditService) GetByIDForAccount(ctx context.Context, accountID uint, id } return audit, nil } + +func marshalAuditChanges(changes interface{}) (json.RawMessage, error) { + if changes == nil { + return json.RawMessage(`{}`), nil + } + if raw, ok := changes.(json.RawMessage); ok { + if len(raw) == 0 { + return json.RawMessage(`{}`), nil + } + return raw, nil + } + data, err := json.Marshal(changes) + if err != nil { + return nil, err + } + return json.RawMessage(data), nil +}