From 0724892e2913f9cbac01735016f2290decf27e52 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sat, 4 Apr 2026 01:19:24 +0300 Subject: [PATCH] feat(backend): implement audit repo and service AuditRepo.Log resolves action_type_id/object_type_id via SQL subqueries. AuditRepo.List supports dynamic filtering by user, action, object type/ID, and date range with COUNT(*) OVER() for total count. AuditService.Log reads user from context, marshals details to JSON, and delegates to the repo. Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/db/postgres/audit_repo.go | 169 +++++++++++++++++++++ backend/internal/service/audit_service.go | 57 +++++++ 2 files changed, 226 insertions(+) create mode 100644 backend/internal/db/postgres/audit_repo.go create mode 100644 backend/internal/service/audit_service.go diff --git a/backend/internal/db/postgres/audit_repo.go b/backend/internal/db/postgres/audit_repo.go new file mode 100644 index 0000000..06a884d --- /dev/null +++ b/backend/internal/db/postgres/audit_repo.go @@ -0,0 +1,169 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "tanabata/backend/internal/db" + "tanabata/backend/internal/domain" + "tanabata/backend/internal/port" +) + +// auditRowWithTotal matches the columns returned by the audit log SELECT. +// object_type is nullable (LEFT JOIN), object_id and details are nullable columns. +type auditRowWithTotal struct { + ID int64 `db:"id"` + UserID int16 `db:"user_id"` + UserName string `db:"user_name"` + Action string `db:"action"` + ObjectType *string `db:"object_type"` + ObjectID *uuid.UUID `db:"object_id"` + Details json.RawMessage `db:"details"` + PerformedAt time.Time `db:"performed_at"` + Total int `db:"total"` +} + +func toAuditEntry(r auditRowWithTotal) domain.AuditEntry { + return domain.AuditEntry{ + ID: r.ID, + UserID: r.UserID, + UserName: r.UserName, + Action: r.Action, + ObjectType: r.ObjectType, + ObjectID: r.ObjectID, + Details: r.Details, + PerformedAt: r.PerformedAt, + } +} + +// AuditRepo implements port.AuditRepo using PostgreSQL. +type AuditRepo struct { + pool *pgxpool.Pool +} + +// NewAuditRepo creates an AuditRepo backed by pool. +func NewAuditRepo(pool *pgxpool.Pool) *AuditRepo { + return &AuditRepo{pool: pool} +} + +var _ port.AuditRepo = (*AuditRepo)(nil) + +// Log inserts one audit record. action_type_id and object_type_id are resolved +// from the reference tables inside the INSERT via subqueries. +func (r *AuditRepo) Log(ctx context.Context, entry domain.AuditEntry) error { + const sql = ` + INSERT INTO activity.audit_log + (user_id, action_type_id, object_type_id, object_id, details) + VALUES ( + $1, + (SELECT id FROM activity.action_types WHERE name = $2), + CASE WHEN $3::text IS NOT NULL + THEN (SELECT id FROM core.object_types WHERE name = $3) + ELSE NULL END, + $4, + $5 + )` + + q := connOrTx(ctx, r.pool) + _, err := q.Exec(ctx, sql, + entry.UserID, + entry.Action, + entry.ObjectType, + entry.ObjectID, + entry.Details, + ) + if err != nil { + return fmt.Errorf("AuditRepo.Log: %w", err) + } + return nil +} + +// List returns a filtered, offset-paginated page of audit log entries ordered +// newest-first. +func (r *AuditRepo) List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) { + var conds []string + args := make([]any, 0, 8) + n := 1 + + if filter.UserID != nil { + conds = append(conds, fmt.Sprintf("a.user_id = $%d", n)) + args = append(args, *filter.UserID) + n++ + } + if filter.Action != "" { + conds = append(conds, fmt.Sprintf("at.name = $%d", n)) + args = append(args, filter.Action) + n++ + } + if filter.ObjectType != "" { + conds = append(conds, fmt.Sprintf("ot.name = $%d", n)) + args = append(args, filter.ObjectType) + n++ + } + if filter.ObjectID != nil { + conds = append(conds, fmt.Sprintf("a.object_id = $%d", n)) + args = append(args, *filter.ObjectID) + n++ + } + if filter.From != nil { + conds = append(conds, fmt.Sprintf("a.performed_at >= $%d", n)) + args = append(args, *filter.From) + n++ + } + if filter.To != nil { + conds = append(conds, fmt.Sprintf("a.performed_at <= $%d", n)) + args = append(args, *filter.To) + n++ + } + + where := "" + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } + + limit := db.ClampLimit(filter.Limit, 50, 200) + offset := db.ClampOffset(filter.Offset) + args = append(args, limit, offset) + + sql := fmt.Sprintf(` + SELECT a.id, a.user_id, u.name AS user_name, + at.name AS action, + ot.name AS object_type, + a.object_id, a.details, + a.performed_at, + COUNT(*) OVER() AS total + FROM activity.audit_log a + JOIN core.users u ON u.id = a.user_id + JOIN activity.action_types at ON at.id = a.action_type_id + LEFT JOIN core.object_types ot ON ot.id = a.object_type_id + %s + ORDER BY a.performed_at DESC + LIMIT $%d OFFSET $%d`, where, n, n+1) + + q := connOrTx(ctx, r.pool) + rows, err := q.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("AuditRepo.List: %w", err) + } + collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[auditRowWithTotal]) + if err != nil { + return nil, fmt.Errorf("AuditRepo.List scan: %w", err) + } + + page := &domain.AuditPage{Offset: offset, Limit: limit} + if len(collected) > 0 { + page.Total = collected[0].Total + } + page.Items = make([]domain.AuditEntry, len(collected)) + for i, row := range collected { + page.Items[i] = toAuditEntry(row) + } + return page, nil +} diff --git a/backend/internal/service/audit_service.go b/backend/internal/service/audit_service.go new file mode 100644 index 0000000..90f2945 --- /dev/null +++ b/backend/internal/service/audit_service.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/port" +) + +// AuditService records user actions to the audit trail. +type AuditService struct { + repo port.AuditRepo +} + +func NewAuditService(repo port.AuditRepo) *AuditService { + return &AuditService{repo: repo} +} + +// Log records an action performed by the user in ctx. +// objectType and objectID are optional — pass nil when the action has no target object. +// details can be any JSON-serializable value, or nil. +func (s *AuditService) Log( + ctx context.Context, + action string, + objectType *string, + objectID *uuid.UUID, + details any, +) error { + userID, _, _ := domain.UserFromContext(ctx) + + var raw json.RawMessage + if details != nil { + b, err := json.Marshal(details) + if err != nil { + return fmt.Errorf("AuditService.Log marshal details: %w", err) + } + raw = b + } + + entry := domain.AuditEntry{ + UserID: userID, + Action: action, + ObjectType: objectType, + ObjectID: objectID, + Details: raw, + } + return s.repo.Log(ctx, entry) +} + +// Query returns a filtered, paginated page of audit log entries. +func (s *AuditService) Query(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) { + return s.repo.List(ctx, filter) +}