feat(audit): align chatwoot audit log payloads

This commit is contained in:
2026-06-05 10:21:15 +08:00
parent c00d313576
commit 3f8f04d65a
5 changed files with 250 additions and 28 deletions
+36 -13
View File
@@ -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.<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.
+64 -7
View File
@@ -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)
}
}
}
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,
}
}
+118 -5
View File
@@ -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)
}
}
func withAuditRole(role string, h gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("role", role)
h(c)
}
}
+21 -2
View File
@@ -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
}
}
+11 -1
View File
@@ -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
}
}
// 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
}