package repository import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "apskel-pos-be/internal/entities" ) // ErrAuditTxRequired is returned when an audit row is written outside a transaction: // the row must commit or roll back together with the change it records. var ErrAuditTxRequired = errors.New("audit: write must run inside a transaction") // AuditLogRepository stores audit_logs (docs/rfc-enakgame.md ยง5.9). type AuditLogRepository interface { // Insert writes a row in the caller's transaction, or returns ErrAuditTxRequired. Insert(ctx context.Context, entry *entities.AuditLog) error // ListByEntity returns an entity's rows in an organization, newest first. ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error) } type auditLogRepository struct { db *gorm.DB } func NewAuditLogRepository(db *gorm.DB) AuditLogRepository { return &auditLogRepository{db: db} } func (r *auditLogRepository) Insert(ctx context.Context, entry *entities.AuditLog) error { tx, ok := ctx.Value(txKey).(*gorm.DB) if !ok || tx == nil { return ErrAuditTxRequired } if entry.ID == uuid.Nil { entry.ID = uuid.New() } var rows []struct{ CreatedAt time.Time } err := tx.WithContext(ctx).Raw(` INSERT INTO audit_logs (id, organization_id, actor_type, actor_id, entity_type, entity_id, action, before, after, reason, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?) RETURNING created_at`, entry.ID, entry.OrganizationID, entry.ActorType, entry.ActorID, entry.EntityType, entry.EntityID, entry.Action, jsonOrNull(entry.Before), jsonOrNull(entry.After), entry.Reason, entry.Source). Scan(&rows).Error if err != nil { return fmt.Errorf("failed to write audit log: %w", err) } if len(rows) == 1 { entry.CreatedAt = rows[0].CreatedAt } return nil } func (r *auditLogRepository) ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error) { var rows []struct { ID string OrganizationID string ActorType string ActorID *string EntityType string EntityID string Action string Before *string After *string Reason *string Source string CreatedAt time.Time } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT id::text AS id, organization_id::text AS organization_id, actor_type, actor_id::text AS actor_id, entity_type, entity_id::text AS entity_id, action, before::text AS before, after::text AS after, reason, source, created_at FROM audit_logs WHERE organization_id = ? AND entity_type = ? AND entity_id = ? ORDER BY created_at DESC, id LIMIT ?`, organizationID, entityType, entityID, limit).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to read audit logs: %w", err) } out := make([]entities.AuditLog, 0, len(rows)) for _, row := range rows { entry := entities.AuditLog{ ActorType: row.ActorType, EntityType: row.EntityType, Action: row.Action, Reason: row.Reason, Source: row.Source, CreatedAt: row.CreatedAt, } entry.ID, _ = uuid.Parse(row.ID) entry.OrganizationID, _ = uuid.Parse(row.OrganizationID) entry.EntityID, _ = uuid.Parse(row.EntityID) if row.ActorID != nil { if id, err := uuid.Parse(*row.ActorID); err == nil { entry.ActorID = &id } } if row.Before != nil { entry.Before = json.RawMessage(*row.Before) } if row.After != nil { entry.After = json.RawMessage(*row.After) } out = append(out, entry) } return out, nil } // jsonOrNull passes a JSON document as text, or NULL when there is none. func jsonOrNull(doc json.RawMessage) *string { if len(doc) == 0 { return nil } s := string(doc) return &s }