// Package audit appends rows to the audit_log table. Every mutation // in the API funnels through this so the operator can answer // "who did what when?" from a single SELECT. package audit import ( "context" "encoding/json" "time" "github.com/jackc/pgx/v5/pgxpool" ) type Repo struct { Pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } // Entry mirrors one audit_log row — ListRecent returns these for // the dashboard's recent-activity card. type Entry struct { ID int64 `json:"id"` Actor string `json:"actor"` Action string `json:"action"` Subject *string `json:"subject,omitempty"` Detail json.RawMessage `json:"detail,omitempty"` NodeID *string `json:"node_id,omitempty"` CreatedAt time.Time `json:"created_at"` } // ListRecent returns the most recent audit entries, newest first. // Pass 0 for a sensible default (10). func (r *Repo) ListRecent(ctx context.Context, limit int) ([]Entry, error) { if r == nil || r.Pool == nil { return []Entry{}, nil } if limit <= 0 || limit > 100 { limit = 10 } rows, err := r.Pool.Query(ctx, ` SELECT id, actor, action, subject, detail, node_id, created_at FROM audit_log ORDER BY created_at DESC, id DESC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() out := make([]Entry, 0, limit) for rows.Next() { var e Entry if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Subject, &e.Detail, &e.NodeID, &e.CreatedAt); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // Log writes one audit_log row. detail is JSON-encodable (typically a // map[string]any) — empty map means "no payload". If pool is nil // (e.g. dev env without DB), Log silently no-ops so handlers don't // have to guard each call site. func (r *Repo) Log(ctx context.Context, actor, action, subject string, detail any, nodeID string) error { if r == nil || r.Pool == nil { return nil } var detailJSON []byte if detail != nil { var err error detailJSON, err = json.Marshal(detail) if err != nil { return err } } var subjectArg any = subject if subject == "" { subjectArg = nil } var nodeArg any = nodeID if nodeID == "" { nodeArg = nil } _, err := r.Pool.Exec(ctx, `INSERT INTO audit_log (actor, action, subject, detail, node_id) VALUES ($1, $2, $3, $4, $5)`, actor, action, subjectArg, detailJSON, nodeArg) return err }