package services import ( "context" "encoding/json" "fmt" "log/slog" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) type AuditService struct { db *sqlx.DB } func NewAuditService(db *sqlx.DB) *AuditService { return &AuditService{db: db} } // Log records an audit entry. It extracts tenant, user, IP, and user-agent from context. // Errors are logged but not returned — audit logging must not break business operations. func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) { tenantID, ok := auth.TenantFromContext(ctx) if !ok { slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType) return } var userID *uuid.UUID if uid, ok := auth.UserFromContext(ctx); ok { userID = &uid } var oldJSON, newJSON *json.RawMessage if oldValues != nil { if b, err := json.Marshal(oldValues); err == nil { raw := json.RawMessage(b) oldJSON = &raw } } if newValues != nil { if b, err := json.Marshal(newValues); err == nil { raw := json.RawMessage(b) newJSON = &raw } } ip := auth.IPFromContext(ctx) ua := auth.UserAgentFromContext(ctx) _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua) if err != nil { slog.Error("audit: failed to write log entry", "error", err, "action", action, "entity_type", entityType, "entity_id", entityID, ) } } // AuditFilter holds query parameters for listing audit log entries. type AuditFilter struct { EntityType string EntityID *uuid.UUID UserID *uuid.UUID From string // RFC3339 date To string // RFC3339 date Page int Limit int } // List returns paginated audit log entries for a tenant. func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) { if filter.Limit <= 0 { filter.Limit = 50 } if filter.Limit > 200 { filter.Limit = 200 } if filter.Page <= 0 { filter.Page = 1 } offset := (filter.Page - 1) * filter.Limit where := "WHERE tenant_id = $1" args := []any{tenantID} argIdx := 2 if filter.EntityType != "" { where += fmt.Sprintf(" AND entity_type = $%d", argIdx) args = append(args, filter.EntityType) argIdx++ } if filter.EntityID != nil { where += fmt.Sprintf(" AND entity_id = $%d", argIdx) args = append(args, *filter.EntityID) argIdx++ } if filter.UserID != nil { where += fmt.Sprintf(" AND user_id = $%d", argIdx) args = append(args, *filter.UserID) argIdx++ } if filter.From != "" { where += fmt.Sprintf(" AND created_at >= $%d", argIdx) args = append(args, filter.From) argIdx++ } if filter.To != "" { where += fmt.Sprintf(" AND created_at <= $%d", argIdx) args = append(args, filter.To) argIdx++ } var total int if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil { return nil, 0, fmt.Errorf("counting audit entries: %w", err) } query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", where, argIdx, argIdx+1) args = append(args, filter.Limit, offset) var entries []models.AuditLog if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil { return nil, 0, fmt.Errorf("listing audit entries: %w", err) } if entries == nil { entries = []models.AuditLog{} } return entries, total, nil }