Ctrl K
ComunidadGuías…

internal/webhooks/webhook_service_event.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/google/uuid"
	jsonschema "github.com/kaptinlin/jsonschema"
	"github.com/riverqueue/river"
	"go.opentelemetry.io/otel/attribute"
	otelcodes "go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/trace"

	svcerrors "github.com/sarathsp06/sparrow/internal/errors"
	"github.com/sarathsp06/sparrow/internal/storage"
	"github.com/sarathsp06/sparrow/internal/tenant"
	"github.com/sarathsp06/sparrow/internal/webhooks/queue"
	"github.com/sarathsp06/sparrow/internal/webhooks/store"
)

// maxEventNameLength matches the VARCHAR(255) event-name columns. Postgres
// counts characters, not bytes, so the check does too.
const maxEventNameLength = 255

const eventNameTooLong = "event name must be at most 255 characters"

// maxScheduleAhead caps how far in the future an event can be scheduled.
// Far-future events would sit in the queue indefinitely and outlive any
// reasonable retention window.
const maxScheduleAhead = 365 * 24 * time.Hour

// PushEvent pushes an event.
// When idempotencyKey is non-nil and non-empty, duplicate detection is
// performed: if an event with the same key already exists within the
// (tenant, consumer), the existing event_id is returned with
// isDuplicate=true and no new event or deliveries are created.
// Re-push/re-enqueue flows pass nil, so they are never deduplicated.
// A non-nil scheduledAt delays the fan-out (and so all deliveries) until
// that time; a past or zero value means immediate.
func (s *WebhookService) PushEvent(ctx context.Context, consumer string, event string, payload map[string]any, ttlSeconds int64, metadata map[string]string, labels map[string]string, idempotencyKey *string, scheduledAt *time.Time) (string, bool, bool, []string, error) {
	span := trace.SpanFromContext(ctx)
	span.SetAttributes(
		attribute.String("consumer", consumer),
		attribute.String("event", event),
	)

	s.logger.InfoContext(ctx, "Processing push event request",
		"consumer", consumer,
		"event", event,
	)

	tenantID := tenant.FromContext(ctx)

	// Validate required fields
	if consumer == "" {
		err := svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "consumer is required")
		return "", false, false, nil, err
	}
	if event == "" {
		err := svcerrors.Error(svcerrors.InvalidArgument, "event is required")
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "event is required")
		return "", false, false, nil, err
	}
	if utf8.RuneCountInString(event) > maxEventNameLength {
		err := svcerrors.Error(svcerrors.InvalidArgument, eventNameTooLong)
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "event name too long")
		return "", false, false, nil, err
	}
	// Both are VARCHAR(255): a longer value failed the INSERT with a 500.
	if utf8.RuneCountInString(consumer) > maxEventNameLength {
		err := svcerrors.Errorf(svcerrors.InvalidArgument, "consumer must be at most %d characters, got %d", maxEventNameLength, utf8.RuneCountInString(consumer))
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "consumer too long")
		return "", false, false, nil, err
	}
	if idempotencyKey != nil && utf8.RuneCountInString(*idempotencyKey) > maxEventNameLength {
		err := svcerrors.Errorf(svcerrors.InvalidArgument, "idempotency_key must be at most %d characters, got %d", maxEventNameLength, utf8.RuneCountInString(*idempotencyKey))
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "idempotency_key too long")
		return "", false, false, nil, err
	}
	if err := validateLabels(labels, "labels"); err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "invalid labels")
		return "", false, false, nil, err
	}
	if IsReservedEventName(event) {
		// Only Sparrow itself emits sparrow.* events (see queue.pushSystemEvent).
		err := reservedEventNameError(event)
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "reserved event name")
		return "", false, false, nil, err
	}

	// Normalize scheduling: past or zero means immediate; cap the horizon.
	now := time.Now()
	if scheduledAt != nil && !scheduledAt.After(now) {
		scheduledAt = nil
	}
	if scheduledAt != nil && scheduledAt.After(now.Add(maxScheduleAhead)) {
		err := svcerrors.Error(svcerrors.InvalidArgument, "scheduled_at must be within one year from now")
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "scheduled_at too far in the future")
		return "", false, false, nil, err
	}

	// Idempotency check: if the caller provided an idempotency key, look up
	// an existing event with the same key in this (tenant, consumer). When
	// found, return the existing event_id immediately — no new record, no
	// new deliveries. This check is intentionally skipped for re-push flows
	// (which pass nil) so that replays always create new events.
	if idempotencyKey != nil && *idempotencyKey != "" {
		existing, err := s.webhookRepo.GetEventByIdempotencyKey(ctx, tenantID, consumer, *idempotencyKey)
		if err != nil {
			span.RecordError(err)
			span.SetStatus(otelcodes.Error, "idempotency lookup failed")
			s.logger.ErrorContext(ctx, "Failed to check idempotency key", "idempotency_key", *idempotencyKey, "error", err)
			return "", false, false, nil, fmt.Errorf("failed to check idempotency key: %w", err)
		}
		if existing != nil {
			span.SetAttributes(attribute.Bool("duplicate", true))
			span.SetStatus(otelcodes.Ok, "duplicate event (idempotent)")
			s.logger.InfoContext(ctx, "Duplicate event detected via idempotency key",
				"idempotency_key", *idempotencyKey,
				"existing_event_id", existing.ID.String(),
			)
			return existing.ID.String(), true, existing.SchemaValid, nil, nil
		}
	}

	// Look up the registered event type. Unknown names are rejected unless
	// auto-registration is enabled (a development convenience; production
	// definitions arrive by register or import).
	eventReg, err := s.webhookRepo.GetEventByName(ctx, tenantID, event)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "event lookup failed")
		s.logger.ErrorContext(ctx, "Failed to lookup event registration", "event", event, "error", err)
		return "", false, false, nil, fmt.Errorf("failed to lookup event registration: %w", err)
	}
	if eventReg == nil {
		if !s.autoRegisterEvents {
			err := svcerrors.Errorf(svcerrors.NotFound, "event type %q is not registered; register or import it before pushing", event)
			span.RecordError(err)
			span.SetStatus(otelcodes.Error, "event type not registered")
			return "", false, false, nil, err
		}
		eventReg, err = s.autoRegisterEvent(ctx, tenantID, event)
		if err != nil {
			span.RecordError(err)
			span.SetStatus(otelcodes.Error, "auto-registration failed")
			return "", false, false, nil, err
		}
	}
	if !eventReg.Active {
		err := svcerrors.Errorf(svcerrors.FailedPrecondition, "event '%s' is inactive", event)
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "event inactive")
		s.logger.ErrorContext(ctx, "Event is inactive", "event", event)
		return "", false, false, nil, err
	}

	// Soft schema validation: validate payload against event schema if present.
	// Events are always accepted and stored regardless of schema match.
	// Invalid payloads are tagged (schema_valid=false) with per-field warnings.
	var warnings []string
	schemaValid := true
	if len(eventReg.Schema) != 0 && payload != nil {
		if err := ValidateJSONSchema(eventReg.Schema, payload); err != nil {
			var schemaErr *SchemaValidationError
			if errors.As(err, &schemaErr) {
				schemaValid = false
				warnings = schemaErr.Warnings()
				s.logger.WarnContext(ctx, "Payload does not match event schema (accepted with warnings)",
					"event", event,
					"warning_count", len(warnings),
				)
				span.SetAttributes(attribute.Bool("schema_valid", false))
			} else {
				// Non-schema error (e.g., schema compilation failure) -- still accept
				schemaValid = false
				warnings = []string{err.Error()}
				s.logger.WarnContext(ctx, "Schema validation encountered unexpected error (accepted with warnings)",
					"event", event,
					"error", err,
				)
			}
		}
	}

	// TTL=0 means no expiry (default). Only positive values enable expiry.
	ttl := ttlSeconds
	if ttl < 0 {
		ttl = 0
	}

	// Generate event ID
	eventID := uuid.New().String()

	// Store the event record in database first
	eventRecord := &store.EventRecord{
		ID:             uuid.MustParse(eventID),
		Consumer:       consumer,
		Event:          event,
		Payload:        payload,
		TTL:            ttl,
		Metadata:       metadata,
		Labels:         labels,
		SchemaValid:    schemaValid,
		EventVersion:   eventReg.Version,
		IdempotencyKey: idempotencyKey,
		CreatedAt:      time.Now(),
		ScheduledAt:    scheduledAt,
	}
	if scheduledAt != nil && ttl > 0 {
		// TTL counts from fan-out, which for a scheduled event is the
		// scheduled time, not the push. Without this the event row (and
		// retention) would expire it before it ever fires.
		eventRecord.ExpiresAt = scheduledAt.Add(time.Duration(ttl) * time.Second)
	}

	eventArgs := queue.EventArgs{
		EventID:    eventID,
		Consumer:   consumer,
		Event:      event,
		TTLSeconds: ttl,
		Metadata:   metadata,
		Labels:     labels,
		CreatedAt:  eventRecord.CreatedAt,
		TenantID:   tenantID.String(),
	}
	if scheduledAt != nil {
		eventArgs.ScheduledAt = *scheduledAt
	}

	// The event row and its processing job commit together: an event is
	// never stored without the job that delivers it, which an idempotent
	// retry would then answer as a duplicate.
	err = s.webhookRepo.StoreEventAndEnqueue(ctx, tenantID, eventRecord, func(ctx context.Context, tx *sql.Tx) error {
		return s.jobInserter.BatchInsertTx(ctx, tx, []river.JobArgs{eventArgs})
	})
	if err != nil {
		// Idempotency race: a concurrent push with the same key won the insert
		// between our lookup above and this insert. Return the winner as a duplicate.
		if errors.Is(err, storage.ErrAlreadyExists) && idempotencyKey != nil && *idempotencyKey != "" {
			existing, lookupErr := s.webhookRepo.GetEventByIdempotencyKey(ctx, tenantID, consumer, *idempotencyKey)
			if lookupErr == nil && existing != nil {
				span.SetAttributes(attribute.Bool("duplicate", true))
				span.SetStatus(otelcodes.Ok, "duplicate event (idempotent)")
				s.logger.InfoContext(ctx, "Duplicate event detected on insert (idempotency race)",
					"idempotency_key", *idempotencyKey,
					"existing_event_id", existing.ID.String(),
				)
				return existing.ID.String(), true, existing.SchemaValid, nil, nil
			}
		}
		s.logger.ErrorContext(ctx, "Failed to store and schedule event", "error", err, "event_id", eventID)
		return "", false, false, nil, fmt.Errorf("failed to store event: %w", err)
	}

	// Record metrics
	if s.metrics != nil {
		s.metrics.EventsPushed.Add(ctx, 1)
	}

	span.SetStatus(otelcodes.Ok, "event scheduled successfully")

	s.logger.InfoContext(ctx, "Event processing scheduled successfully",
		"event_id", eventID,
		"consumer", consumer,
		"event", event,
	)
	return eventID, false, schemaValid, warnings, nil
}

// RePushEvent replays a previously pushed event as if it were pushed fresh.
// It loads the original event record and calls PushEvent with the same payload,
// consumer, event name, metadata, and labels. The payload is validated against
// the CURRENT event type schema. Returns a new event_id and any warnings.
func (s *WebhookService) RePushEvent(ctx context.Context, eventID string) (string, []string, error) {
	span := trace.SpanFromContext(ctx)
	span.SetAttributes(
		attribute.String("original_event_id", eventID),
	)

	s.logger.InfoContext(ctx, "Processing single event re-push",
		"original_event_id", eventID,
	)

	tenantID := tenant.FromContext(ctx)

	// Parse and validate event ID
	id, err := parseUUID(eventID, "event ID")
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "invalid event ID")
		return "", nil, err
	}

	// Load original event record
	original, err := s.webhookRepo.GetEventByID(ctx, tenantID, id)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "failed to load original event")
		s.logger.ErrorContext(ctx, "Failed to load original event for re-push",
			"event_id", eventID,
			"error", err,
		)
		return "", nil, fmt.Errorf("failed to load original event: %w", err)
	}
	if original == nil {
		err := svcerrors.Errorf(svcerrors.NotFound, "event not found: %s", eventID)
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "event not found")
		return "", nil, err
	}

	// Re-push through the standard PushEvent pipeline with nil idempotency key.
	// This ensures re-pushes always create new events and are never deduplicated.
	// This gives us: current schema validation, new event_id, fan-out to matching subscriptions.
	newEventID, _, _, warnings, err := s.PushEvent(ctx, original.Consumer, original.Event, original.Payload, original.TTL, original.Metadata, original.Labels, nil, nil)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "re-push failed")
		s.logger.ErrorContext(ctx, "Failed to re-push event",
			"original_event_id", eventID,
			"error", err,
		)
		return "", nil, fmt.Errorf("failed to re-push event: %w", err)
	}

	span.SetStatus(otelcodes.Ok, "event re-pushed successfully")
	span.SetAttributes(attribute.String("new_event_id", newEventID))

	s.logger.InfoContext(ctx, "Event re-pushed successfully",
		"original_event_id", eventID,
		"new_event_id", newEventID,
	)
	return newEventID, warnings, nil
}

// GetEventRecord retrieves a single pushed event instance by UUID with delivery statistics.
func (s *WebhookService) GetEventRecord(ctx context.Context, eventID string) (*store.EventRecord, int32, int32, int32, int32, error) {
	span := trace.SpanFromContext(ctx)
	span.SetAttributes(
		attribute.String("event_id", eventID),
	)

	tenantID := tenant.FromContext(ctx)

	// Parse and validate event ID
	id, err := parseUUID(eventID, "event ID")
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "invalid event ID")
		return nil, 0, 0, 0, 0, err
	}

	// Load event record
	record, err := s.webhookRepo.GetEventByID(ctx, tenantID, id)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "failed to load event record")
		return nil, 0, 0, 0, 0, fmt.Errorf("failed to load event record: %w", err)
	}
	if record == nil {
		return nil, 0, 0, 0, 0, svcerrors.Error(svcerrors.NotFound, "event record not found")
	}

	// Get delivery statistics
	webhookCount, successCount, failedCount, pendingCount, err := s.webhookRepo.GetEventDeliveryStats(ctx, tenantID, id)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(otelcodes.Error, "failed to get delivery stats")
		s.logger.ErrorContext(ctx, "Failed to get delivery stats for event record",
			"event_id", eventID,
			"error", err,
		)
		// Return the record without stats rather than failing entirely
		return record, 0, 0, 0, 0, nil
	}

	return record, webhookCount, successCount, failedCount, pendingCount, nil
}

// autoRegisterEvent creates a schema-less event type on first push, when
// SPARROW_AUTO_REGISTER_EVENTS is on. A later register or import that adds a
// schema fills in this version 1 rather than creating version 2.
func (s *WebhookService) autoRegisterEvent(ctx context.Context, tenantID uuid.UUID, event string) (*store.EventRegistration, error) {
	if err := validateNewEventTypeName(event); err != nil {
		return nil, err
	}
	reg := &store.EventRegistration{Name: event, Active: true, Version: 1}
	err := s.webhookRepo.RegisterEvent(ctx, tenantID, reg)
	if errors.Is(err, storage.ErrAlreadyExists) {
		// A concurrent push registered it first; use that one.
		existing, getErr := s.webhookRepo.GetEventByName(ctx, tenantID, event)
		if getErr != nil {
			return nil, fmt.Errorf("failed to reload auto-registered event: %w", getErr)
		}
		if existing != nil {
			return existing, nil
		}
	}
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to auto-register event", "event", event, "error", err)
		return nil, fmt.Errorf("failed to auto-register event: %w", err)
	}
	s.logger.InfoContext(ctx, "Auto-registered new event type", "event", event)
	return reg, nil
}

// RegisterEvent registers a new event type at version 1. It fails with
// AlreadyExists if the name is taken; use UpdateEvent to change a type.
func (s *WebhookService) RegisterEvent(ctx context.Context, name string, description string, schema map[string]any, metadata map[string]string, active bool) (string, time.Time, error) {
	s.logger.InfoContext(ctx, "Processing event registration request", "name", name, "description", description)
	res, err := s.saveEventType(ctx, EventTypeDefinition{
		Name:        name,
		Description: description,
		Schema:      schema,
		Metadata:    metadata,
		Active:      active,
	}, saveCreateOnly, saveEventTypeOptions{})
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to register event", "name", name, "error", err)
		return "", time.Time{}, err
	}
	s.logger.InfoContext(ctx, "Event registered successfully", "name", name, "version", res.Version)
	return res.Name, res.Event.CreatedAt, nil
}

// ListEvents lists the event types consumer can subscribe to (see
// checkEventForConsumer): Sparrow's own sparrow.* events for the system
// consumer, tenant event types for any other consumer or none.
func (s *WebhookService) ListEvents(ctx context.Context, activeOnly bool, consumer string, limit, offset int32) ([]*store.EventRegistration, int32, error) {
	s.logger.InfoContext(ctx, "Processing list events request",
		"active_only", activeOnly, "consumer", consumer, "limit", limit, "offset", offset)

	tenantID := tenant.FromContext(ctx)

	// Event types are tenant-scoped

	l, o := normalizePagination(int(limit), int(offset))
	limit, offset = clampInt32(l), clampInt32(o)

	filter := store.EventTypeFilter{ActiveOnly: activeOnly, System: eventTypesForConsumer(consumer)}
	events, totalCount, err := s.webhookRepo.ListEventsPaginated(ctx, tenantID, filter, int(limit), int(offset))
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to list events", "error", err)
		return nil, 0, fmt.Errorf("failed to retrieve events: %w", err)
	}
	s.logger.InfoContext(ctx, "Listed events successfully",
		"count", len(events),
		"total", totalCount,
	)
	return events, clampInt32(totalCount), nil
}

// UpdateEvent replaces an existing event type's definition. A schema change
// creates a new version; everything else changes the current version in
// place. See planEventTypeSave for the exact rules.
//
// A breaking schema change (see ClassifySchemaChange) to an event type that
// subscriptions receive fails with FailedPrecondition unless allowBreaking.
func (s *WebhookService) UpdateEvent(ctx context.Context, name string, description string, schema map[string]any, metadata map[string]string, active bool, allowBreaking bool) (*EventTypeSaveResult, error) {
	s.logger.InfoContext(ctx, "Processing event update request", "name", name)
	res, err := s.saveEventType(ctx, EventTypeDefinition{
		Name:        name,
		Description: description,
		Schema:      schema,
		Metadata:    metadata,
		Active:      active,
	}, saveMustExist, saveEventTypeOptions{AllowBreaking: allowBreaking})
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to update event", "name", name, "error", err)
		return nil, err
	}
	s.logger.InfoContext(ctx, "Event updated", "name", name, "action", res.Action, "version", res.Version)
	return res, nil
}

// ListEventTypeVersions returns every version of an event type, newest first.
func (s *WebhookService) ListEventTypeVersions(ctx context.Context, name string) ([]*store.EventRegistrationVersion, error) {
	if _, err := s.GetEvent(ctx, name); err != nil {
		return nil, err
	}
	versions, err := s.webhookRepo.ListEventTypeVersions(ctx, tenant.FromContext(ctx), name)
	if err != nil {
		return nil, fmt.Errorf("failed to list event type versions: %w", err)
	}
	return versions, nil
}

// GetEventTypeVersion returns one version of an event type.
func (s *WebhookService) GetEventTypeVersion(ctx context.Context, name string, version int) (*store.EventRegistrationVersion, error) {
	if name == "" {
		return nil, svcerrors.Error(svcerrors.InvalidArgument, "event name is required")
	}
	v, err := s.webhookRepo.GetEventTypeVersion(ctx, tenant.FromContext(ctx), name, version)
	if err != nil {
		return nil, fmt.Errorf("failed to get event type version: %w", err)
	}
	if v == nil {
		return nil, svcerrors.Errorf(svcerrors.NotFound, "event type %q has no version %d", name, version)
	}
	return v, nil
}

// GetEvent retrieves an event registration by name
func (s *WebhookService) GetEvent(ctx context.Context, name string) (*store.EventRegistration, error) {
	s.logger.InfoContext(ctx, "Processing get event request", "name", name)
	if name == "" {
		return nil, svcerrors.Error(svcerrors.InvalidArgument, "event name is required")
	}

	tenantID := tenant.FromContext(ctx)

	// Event types are tenant-scoped

	event, err := s.webhookRepo.GetEventByName(ctx, tenantID, name)
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to get event", "error", err)
		return nil, fmt.Errorf("failed to retrieve event: %w", err)
	}
	if event == nil {
		return nil, svcerrors.Error(svcerrors.NotFound, "event type not found")
	}

	return event, nil
}

// SchemaValidationError represents a structured schema validation failure
// with per-field error details that can be displayed to the user.
type SchemaValidationError struct {
	Message string            `json:"message"`
	Details map[string]string `json:"details"` // field path -> error message
}

func (e *SchemaValidationError) Error() string {
	if len(e.Details) == 0 {
		return e.Message
	}
	var parts []string
	for field, msg := range e.Details {
		if field == "" {
			parts = append(parts, msg)
		} else {
			parts = append(parts, fmt.Sprintf("%s: %s", field, msg))
		}
	}
	return fmt.Sprintf("%s: %s", e.Message, strings.Join(capWarnings(parts), "; "))
}

// maxSchemaWarnings bounds the messages reported for one payload. A large
// array of wrongly typed items gets one per item, which made a 1 MiB push
// answer with a response about 20 times larger.
const maxSchemaWarnings = 100

// capWarnings sorts warnings, so the ones kept do not change from push to
// push, and replaces those past maxSchemaWarnings with a count.
func capWarnings(warnings []string) []string {
	if len(warnings) <= maxSchemaWarnings {
		return warnings
	}
	slices.Sort(warnings)
	more := len(warnings) - maxSchemaWarnings
	return append(warnings[:maxSchemaWarnings:maxSchemaWarnings], fmt.Sprintf("... and %d more schema warnings", more))
}

// Warnings returns per-field validation messages suitable for API responses.
// Each warning is a human-readable string describing a specific validation failure.
func (e *SchemaValidationError) Warnings() []string {
	var warnings []string
	for field, msg := range e.Details {
		if field == "" {
			warnings = append(warnings, msg)
		} else {
			warnings = append(warnings, fmt.Sprintf("field '%s': %s", field, msg))
		}
	}
	if len(warnings) == 0 {
		warnings = append(warnings, e.Message)
	}
	return capWarnings(warnings)
}

// newSchemaCompiler returns a compiler that never fetches a remote $ref. The
// library registers plain http/https loaders by default, which would let
// anyone who can save a schema make the server GET any URL, outside the
// delivery network policy and without a size limit.
func newSchemaCompiler() *jsonschema.Compiler {
	c := jsonschema.NewCompiler()
	clear(c.Loaders)
	return c
}

// ValidateJSONSchema validates a payload against a JSON schema string.
// Returns a SchemaValidationError with detailed per-field errors on failure.
func ValidateJSONSchema(schema map[string]any, payload map[string]any) error {
	schemaJSON, err := json.Marshal(schema)
	if err != nil {
		return fmt.Errorf("failed to marshal schema: %w", err)
	}
	sch, err := newSchemaCompiler().Compile(schemaJSON)
	if err != nil {
		return fmt.Errorf("invalid event schema: %w", err)
	}
	payloadBytes, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("failed to marshal payload: %w", err)
	}
	result := sch.ValidateJSON(payloadBytes)
	if result == nil || result.Valid {
		return nil
	}

	// Extract detailed per-field errors from the evaluation result
	details := result.DetailedErrors()

	return &SchemaValidationError{
		Message: "payload validation failed",
		Details: details,
	}
}

// ListEventReports lists event records with delivery statistics in descending order by creation time.
// Supports filtering by consumer, event name, schema_valid, labels, and time range.
// When PrepareRepush is true, snapshots all matching event IDs into a batch job and returns the batch ID.
func (s *WebhookService) ListEventReports(ctx context.Context, filter store.EventReportFilter) (*EventReportPage, error) {
	span := trace.SpanFromContext(ctx)

	s.logger.InfoContext(ctx, "Processing list event reports request",
		"consumer", filter.Consumer,
		"event_name", filter.EventName,
		"prepare_repush", filter.PrepareRepush,
		"limit", filter.Limit)

	tenantID := tenant.FromContext(ctx)

	filter.Limit, _ = normalizePagination(filter.Limit, 0)

	events, hasMore, err := s.webhookRepo.ListEventReportsFiltered(ctx, tenantID, filter)
	if err != nil {
		s.logger.ErrorContext(ctx, "Failed to list event reports", "consumer", filter.Consumer, "event_name", filter.EventName, "error", err)
		span.SetStatus(otelcodes.Error, err.Error())
		return nil, fmt.Errorf("failed to list event reports: %w", err)
	}

	// Snapshot matching IDs into a batch job if requested
	page := &EventReportPage{Items: events, HasMore: hasMore}
	if filter.PrepareRepush {
		ids, err := s.webhookRepo.SnapshotEventIDs(ctx, tenantID, filter)
		if err != nil {
			s.logger.ErrorContext(ctx, "Failed to snapshot event IDs for repush", "error", err)
			return nil, fmt.Errorf("failed to prepare repush: %w", err)
		}
		if len(ids) > 0 {
			filterMap := map[string]any{
				"consumer": filter.Consumer,
			}
			if filter.EventName != nil {
				filterMap["event_name"] = *filter.EventName
			}
			if filter.SchemaValid != nil {
				filterMap["schema_valid"] = *filter.SchemaValid
			}
			if len(filter.Labels) > 0 {
				filterMap["labels"] = filter.Labels
			}
			batchData := &store.BatchJobData{
				ItemIDs: ids,
				Filter:  filterMap,
			}
			batchJob, err := s.webhookRepo.CreateBatchJob(ctx, tenantID, filter.Consumer, store.BatchTypeEventRepush, batchData)
			if err != nil {
				s.logger.ErrorContext(ctx, "Failed to create batch job for repush", "error", err)
				return nil, fmt.Errorf("failed to create repush batch: %w", err)
			}
			page.RepushID = batchJob.ID.String()
			page.RepushTotal = len(ids)
			s.logger.InfoContext(ctx, "Created repush batch job",
				"repush_id", page.RepushID,
				"event_count", len(ids))
		}
	}

	s.logger.InfoContext(ctx, "Successfully listed event reports",
		"consumer", filter.Consumer,
		"event_name", filter.EventName,
		"count", len(events),
		"has_more", hasMore)

	span.SetAttributes(
		attribute.String("consumer", filter.Consumer),
		attribute.Int("count", len(events)),
		attribute.Bool("has_more", hasMore),
	)
	if filter.EventName != nil {
		span.SetAttributes(attribute.String("event_name", *filter.EventName))
	}

	return page, nil
}