From 3f8f04d65abd967d7b307758a4f184e57c7460d6 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 10:21:15 +0800 Subject: [PATCH] feat(audit): align chatwoot audit log payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 49 +++++-- internal/handler/api/v1/audit_handler.go | 71 +++++++++- internal/handler/api/v1/audit_handler_test.go | 123 +++++++++++++++++- internal/repository/audit_repo.go | 23 +++- internal/service/audit_service.go | 12 +- 5 files changed, 250 insertions(+), 28 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 13e8bb52..840fc315 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(macros): align chatwoot macro payloads`. -- Latest documentation checkpoint: this checkpoint, recorded with the B9.2 macro implementation. -- Worktree status at this implementation checkpoint: B9.2 macro CRUD payloads and execute side effects are implemented; next active slice is B10 audit/custom roles/remaining limits unless B9.3 delayed/durable worker scheduling is pulled forward. +- 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. - `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,9 +44,10 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. | -| 2 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. | -| 3 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | +| 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/`. | ## Execution Snapshot @@ -140,12 +141,13 @@ This ledger records the committed parity checkpoints that future slices should b | `docs: land automation delivery plan` | Recorded the complete B9.1d retryable external-action landing contract: webhook payload/delivery, transcript mailer boundary, retry/timeout behavior, action-result observability fields, B9.3 durable-worker deferral, and exit commands. It also refreshed stale B9 tracker entries that still pointed at B9.1b. | Documentation-only checkpoint; `git diff --check` before commit. | Start B9.1d implementation, then continue B9.2 macro CRUD/execute parity. | | `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. | ## Next Slice Contract -Completed implementation slice: B5.1-B5.5 now cover inbox serializer shape, Chatwoot frontend create/update binding, working-hours persistence, out-of-office behavior, inbox member assignment payload/mutation semantics, channel-specific config depth, AgentCapacityPolicy/InboxCapacityLimit API data contracts, and dedicated Email/Twilio/LINE channel route response shapes. B6 is now in Review after `a16c23c` and `f08c743`: Chatwoot search response envelopes, frontend query params, Meilisearch sender filters, mocked hit serialization, env-gated live Meilisearch validation, release-mode DB fallback rejection, and reindex Meilisearch-only guard are covered. B7 is now in Review after `a98dc2c`, `95224fa`, `a11bb96`, `213bf2b`, `4f85ef1`, and `d23f3f0`, enforcing per-inbox capacity limits, aligning SLA policy CRUD payloads, persisting applied SLA records, making core FRT/NRT/RT breach events idempotent, aligning the applied-SLA reports API, and creating SLA miss notifications. B3 and B4 remain in review for deeper side effects and browser validation. +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. -Next implementation slice: start B10 audit/custom roles/remaining limit parity now that B9 automation and macro frontend-critical API/action behavior is in Review. B9.3 delayed/durable worker scheduling remains a named Phase 5 follow-up. +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. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -389,7 +391,7 @@ Upcoming enterprise task boards: | B9 | B9.2 | Align macro CRUD/availability and macro execution side effects. | Chatwoot macros controller/model/action execution. | Macro handler/service tests cover frontend payloads and conversation mutations. | Review; frontend-critical CRUD/execute done by `feat(macros): align chatwoot macro payloads` | | 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. | 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 | | 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 | @@ -605,11 +607,31 @@ 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. | Todo | +| 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.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 | +B10.1 current checkpoint: + +- `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/audit_logs_controller.rb`, `enterprise/app/views/api/v1/accounts/audit_logs/show.json.jbuilder`, `enterprise/app/models/enterprise/audit_log.rb`, and dashboard `auditLogs.js`/store were used as the reference contract. +- `GET /api/v1/accounts/:account_id/audit_logs/` now returns Chatwoot's top-level audit payload: `per_page`, `total_entries`, `current_page`, and `audit_logs`, without the local `{ success, data, meta }` wrapper. +- Audit list pagination is fixed to 25 rows per page like `RESULTS_PER_PAGE = 25`; client `per_page` overrides are ignored. +- Audit rows are account-scoped through either local `account_id` or Chatwoot-style `associated_type = Account` plus `associated_id = account_id`. +- Audit list/get now require `administrator` or `super_admin` role, matching the enterprise controller's admin authorization gate. +- The audit serializer emits Jbuilder-compatible fields including actor metadata, request UUID, remote address, version, comment, audited changes, Unix `created_at`, and an explicit `auditable` key. Full polymorphic `auditable.push_event_data` depth remains a B10.2/B10 writer-depth follow-up where the mutating resources are wired. + +B10.1 verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run Audit -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/repository -run Audit -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 | @@ -715,7 +737,7 @@ Work proceeds top-down unless a failing test or frontend blocker forces a narrow | B7 | SLA and assignment capacity. | Chatwoot enterprise SLA and assignment policy behavior. | Unit/integration tests for SLA state, breach, assignment capacity, policy selection. | Review | | B8 | CSAT account/public/send completion. | Chatwoot CSAT survey responses, reports, downloads, listeners. | Metrics/list/review/download/send idempotency tests. | Review | | B9 | Automation/macros durable side effects. | Chatwoot automation/macro services and jobs. | Action execution, logs, webhook/email transcript retry tests. | Review | -| B10 | Audit, CustomRole, InboxLimit. | Chatwoot enterprise admin behavior and policies. | Authorization, audit emission, limits enforcement, admin payload fixtures. | Todo | +| B10 | Audit, CustomRole, InboxLimit. | Chatwoot enterprise admin behavior and policies. | Authorization, audit emission, limits enforcement, admin payload fixtures. | Doing | | B11 | Captain/Copilot deep behavior. | Chatwoot Captain/Copilot controllers, services, frontend clients. | Assistant/tool/document/scenario/copilot thread/task tests and feature gates. | Todo | | B12 | Frontend smoke harness. | `reference/chatwoot` frontend. | Repeatable smoke command and checked gap report. | Todo | @@ -1002,7 +1024,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` | Ensure every mutating enterprise/core action emits audit events and filters match Chatwoot. | Todo | +| 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.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 | @@ -1020,7 +1042,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. | Representative mutations across core and enterprise features emit audit records. | Todo | +| 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 | | 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 | @@ -1251,3 +1273,4 @@ Verification milestone gates: - 2026-06-05: B9.1d planning checkpoint prepared as `docs: land automation delivery plan`; the active tracker now points at B9.1d, records the Chatwoot webhook/transcript reference contracts, scopes the retryable/testable action delivery boundary, names B9.3 durable-worker follow-up work, and lists the required focused/full verification commands before macro work starts. - 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. diff --git a/internal/handler/api/v1/audit_handler.go b/internal/handler/api/v1/audit_handler.go index aa9785a4..7bc1d3ab 100644 --- a/internal/handler/api/v1/audit_handler.go +++ b/internal/handler/api/v1/audit_handler.go @@ -6,12 +6,14 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) +const auditLogsPerPage = 25 + // AuditHandler handles audit log listing and retrieval. // Reference: Chatwoot enterprise audit logs controller + P2B M11 spec type AuditHandler struct { @@ -32,20 +34,29 @@ func (h *AuditHandler) List(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } + if !isAuditAdmin(c) { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "administrator role required") + return + } - pg := pagination.Parse(c) + page := parseAuditPage(c.Query("page")) action := c.Query("action") auditableType := c.Query("auditable_type") - audits, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, action, auditableType, pg.Page, pg.PerPage) + audits, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, action, auditableType, page, auditLogsPerPage) if svcErr != nil { applogger.L().Errorf("AuditHandler.List account=%d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } - response.OKWithMeta(c, toInterfaceSlice(audits), pg.Page, pg.PerPage, total) + c.JSON(http.StatusOK, gin.H{ + "per_page": auditLogsPerPage, + "total_entries": total, + "current_page": page, + "audit_logs": serializeAuditLogs(audits), + }) } // Get retrieves a single audit log entry by ID. @@ -56,6 +67,10 @@ func (h *AuditHandler) Get(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } + if !isAuditAdmin(c) { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "administrator role required") + return + } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -63,14 +78,14 @@ func (h *AuditHandler) Get(c *gin.Context) { return } - audit, svcErr := h.svc.GetByID(c.Request.Context(), uint(id)) + audit, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, uint(id)) if svcErr != nil { applogger.L().Errorf("AuditHandler.Get id=%d: %v", id, svcErr) handleServiceError(c, svcErr) return } - response.OK(c, audit) + c.JSON(http.StatusOK, serializeAuditLog(*audit)) } // RegisterAuditRoutes registers audit log routes on a gin.RouterGroup. @@ -80,4 +95,46 @@ func RegisterAuditRoutes(rg *gin.RouterGroup, h *AuditHandler) { audits.GET("/", h.List) audits.GET("/:id", h.Get) } -} \ No newline at end of file +} + +func isAuditAdmin(c *gin.Context) bool { + role := getRole(c) + return role == "administrator" || role == "super_admin" +} + +func parseAuditPage(raw string) int { + page, err := strconv.Atoi(raw) + if err != nil || page < 1 { + return 1 + } + return page +} + +func serializeAuditLogs(audits []model.Audit) []gin.H { + items := make([]gin.H, 0, len(audits)) + for _, audit := range audits { + items = append(items, serializeAuditLog(audit)) + } + return items +} + +func serializeAuditLog(audit model.Audit) gin.H { + return gin.H{ + "id": audit.ID, + "auditable_id": audit.AuditableID, + "auditable_type": audit.AuditableType, + "auditable": nil, + "associated_id": audit.AssociatedID, + "associated_type": audit.AssociatedType, + "user_id": audit.UserID, + "user_type": audit.UserType, + "username": audit.Username, + "action": audit.Action, + "audited_changes": audit.AuditedChanges, + "version": audit.Version, + "comment": audit.Comment, + "request_uuid": audit.RequestUUID, + "created_at": audit.CreatedAt.Unix(), + "remote_address": audit.RemoteAddress, + } +} diff --git a/internal/handler/api/v1/audit_handler_test.go b/internal/handler/api/v1/audit_handler_test.go index 8a3a64c1..90b64e12 100644 --- a/internal/handler/api/v1/audit_handler_test.go +++ b/internal/handler/api/v1/audit_handler_test.go @@ -1,10 +1,12 @@ package v1 import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" @@ -52,24 +54,135 @@ func TestAuditHandlerSuite(t *testing.T) { suite.Run(t, new(AuditHandlerTestSuite)) } -func (s *AuditHandlerTestSuite) TestList_Success() { +func (s *AuditHandlerTestSuite) SetupTest() { + s.Require().NoError(s.db.Exec("DELETE FROM audits").Error) +} + +func (s *AuditHandlerTestSuite) TestList_ChatwootPayloadFiltersAndSerializer() { r := gin.New() - r.GET("/api/v1/accounts/:account_id/audit_logs", s.handler.List) + r.GET("/api/v1/accounts/:account_id/audit_logs", withAuditRole("administrator", s.handler.List)) + + createdAt := time.Unix(1710000000, 0).UTC() + userID := uint(42) + associatedID := s.account.ID + version := 3 + audit := &model.Audit{ + AccountID: &s.account.ID, + UserID: &userID, + AuditableType: "Conversation", + AuditableID: 777, + Action: "update", + AuditedChanges: json.RawMessage(`{"status":["open","resolved"]}`), + AssociatedType: "Account", + AssociatedID: &associatedID, + Username: "admin@example.com", + RemoteAddress: "203.0.113.10", + RequestUUID: "req-123", + Version: &version, + Comment: "status changed", + UserType: "User", + CreatedAt: createdAt, + } + s.Require().NoError(s.db.Create(audit).Error) + + otherAccount := &model.Account{Name: "other-audit-account"} + s.Require().NoError(s.db.Create(otherAccount).Error) + s.Require().NoError(s.db.Create(&model.Audit{ + AccountID: &otherAccount.ID, + AuditableType: "Conversation", + AuditableID: 999, + Action: "update", + AuditedChanges: json.RawMessage(`{}`), + }).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/audit_logs?page=1&per_page=100&action=update&auditable_type=Conversation", s.account.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var body map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.NotContains(s.T(), body, "success") + assert.Equal(s.T(), float64(25), body["per_page"]) + assert.Equal(s.T(), float64(1), body["current_page"]) + assert.Equal(s.T(), float64(1), body["total_entries"]) + + logs := body["audit_logs"].([]any) + s.Require().Len(logs, 1) + log := logs[0].(map[string]any) + assert.Equal(s.T(), float64(audit.ID), log["id"]) + assert.Equal(s.T(), float64(777), log["auditable_id"]) + assert.Equal(s.T(), "Conversation", log["auditable_type"]) + assert.Contains(s.T(), log, "auditable") + assert.Nil(s.T(), log["auditable"]) + assert.Equal(s.T(), float64(s.account.ID), log["associated_id"]) + assert.Equal(s.T(), "Account", log["associated_type"]) + assert.Equal(s.T(), float64(userID), log["user_id"]) + assert.Equal(s.T(), "User", log["user_type"]) + assert.Equal(s.T(), "admin@example.com", log["username"]) + assert.Equal(s.T(), "update", log["action"]) + assert.Equal(s.T(), float64(version), log["version"]) + assert.Equal(s.T(), "status changed", log["comment"]) + assert.Equal(s.T(), "req-123", log["request_uuid"]) + assert.Equal(s.T(), float64(createdAt.Unix()), log["created_at"]) + assert.Equal(s.T(), "203.0.113.10", log["remote_address"]) +} + +func (s *AuditHandlerTestSuite) TestList_AssociatedAccountScopeAndFixedPageSize() { + r := gin.New() + r.GET("/api/v1/accounts/:account_id/audit_logs", withAuditRole("administrator", s.handler.List)) + + associatedID := s.account.ID + for i := 0; i < 26; i++ { + s.Require().NoError(s.db.Create(&model.Audit{ + AuditableType: "Conversation", + AuditableID: uint(i + 1), + Action: "update", + AuditedChanges: json.RawMessage(`{}`), + AssociatedType: "Account", + AssociatedID: &associatedID, + CreatedAt: time.Unix(int64(1710000000+i), 0), + }).Error) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/audit_logs?page=2&per_page=1", s.account.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var body map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(s.T(), float64(25), body["per_page"]) + assert.Equal(s.T(), float64(2), body["current_page"]) + assert.Equal(s.T(), float64(26), body["total_entries"]) + assert.Len(s.T(), body["audit_logs"].([]any), 1) +} + +func (s *AuditHandlerTestSuite) TestList_UnauthorizedForAgent() { + r := gin.New() + r.GET("/api/v1/accounts/:account_id/audit_logs", withAuditRole("agent", s.handler.List)) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/audit_logs", s.account.ID), nil) r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), http.StatusUnauthorized, w.Code) } func (s *AuditHandlerTestSuite) TestGet_BadRequest_InvalidID() { r := gin.New() - r.GET("/api/v1/accounts/:account_id/audit_logs/:id", s.handler.Get) + r.GET("/api/v1/accounts/:account_id/audit_logs/:id", withAuditRole("administrator", s.handler.Get)) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/audit_logs/abc", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file +} + +func withAuditRole(role string, h gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("role", role) + h(c) + } +} diff --git a/internal/repository/audit_repo.go b/internal/repository/audit_repo.go index 46dad940..66f3232f 100644 --- a/internal/repository/audit_repo.go +++ b/internal/repository/audit_repo.go @@ -28,10 +28,20 @@ func (r *AuditRepo) FindByID(ctx context.Context, id uint) (*model.Audit, error) return &audit, nil } +// FindByIDForAccount retrieves an audit log entry scoped to the account's +// associated audits, matching Chatwoot's Current.account.associated_audits. +func (r *AuditRepo) FindByIDForAccount(ctx context.Context, accountID uint, id uint) (*model.Audit, error) { + var audit model.Audit + if err := r.accountScopeQuery(ctx, accountID).Where("id = ?", id).First(&audit).Error; err != nil { + return nil, err + } + return &audit, nil +} + // FindByAccount retrieves audit log entries for an account with optional filters. // Returns the filtered list and total count for pagination. func (r *AuditRepo) FindByAccount(ctx context.Context, accountID uint, actionFilter string, auditableTypeFilter string, offset int, limit int) ([]model.Audit, int64, error) { - query := r.db.WithContext(ctx).Where("account_id = ?", accountID) + query := r.accountScopeQuery(ctx, accountID) if actionFilter != "" { query = query.Where("action = ?", actionFilter) @@ -53,6 +63,15 @@ func (r *AuditRepo) FindByAccount(ctx context.Context, accountID uint, actionFil return audits, total, nil } +func (r *AuditRepo) accountScopeQuery(ctx context.Context, accountID uint) *gorm.DB { + return r.db.WithContext(ctx).Where( + "account_id = ? OR (associated_type = ? AND associated_id = ?)", + accountID, + "Account", + accountID, + ) +} + // Create creates a new audit log entry. func (r *AuditRepo) Create(ctx context.Context, audit *model.Audit) error { return r.db.WithContext(ctx).Create(audit).Error @@ -61,4 +80,4 @@ func (r *AuditRepo) Create(ctx context.Context, audit *model.Audit) error { // Delete deletes an audit log entry by primary key. func (r *AuditRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.Audit{}, id).Error -} \ No newline at end of file +} diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index d3f7dfea..ca56a72f 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -60,4 +60,14 @@ func (s *AuditService) GetByID(ctx context.Context, id uint) (*model.Audit, erro return nil, fmt.Errorf("audit log not found: %w", err) } return audit, nil -} \ No newline at end of file +} + +// GetByIDForAccount retrieves a single audit log entry scoped to an account. +func (s *AuditService) GetByIDForAccount(ctx context.Context, accountID uint, id uint) (*model.Audit, error) { + audit, err := s.repo.FindByIDForAccount(ctx, accountID, id) + if err != nil { + applogger.L().Errorf("AuditService.GetByIDForAccount account=%d id=%d: %v", accountID, id, err) + return nil, fmt.Errorf("audit log not found: %w", err) + } + return audit, nil +}