feat(audit): record enterprise mutations
This commit is contained in:
@@ -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.<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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user