package service import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // ReportingBackfillService backfills missing rollup data for a given account and date. // Reference: Chatwoot app/services/reporting_events/backfill_service.rb (142行) // // It aggregates raw ReportingEvent rows into ReportingEventsRollup rows per dimension // (account, agent, inbox) per metric, exactly matching Chatwoot's SQL semantics: // - COUNT/SUM/DURATION aggregation per (dimension_type, dimension_id, metric) // - DISTINCT_COUNT for bot_handoff events // - Business-hours value tracking type ReportingBackfillService struct { eventRepo *repository.ReportingEventRepo rollupRepo *repository.ReportingEventsRollupRepo } // NewReportingBackfillService creates a new backfill service. func NewReportingBackfillService(eventRepo *repository.ReportingEventRepo, rollupRepo *repository.ReportingEventsRollupRepo) *ReportingBackfillService { return &ReportingBackfillService{eventRepo: eventRepo, rollupRepo: rollupRepo} } // DimensionSpec defines a rollup dimension with its group column. type DimensionSpec struct { Type model.DimensionType GroupColumn string // empty for account dimension } // BackfillDimensions mirrors Chatwoot DIMENSIONS constant. var BackfillDimensions = []DimensionSpec{ {Type: model.DimensionAccount, GroupColumn: ""}, {Type: model.DimensionAgent, GroupColumn: "user_id"}, {Type: model.DimensionInbox, GroupColumn: "inbox_id"}, } // DistinctCountEvents mirrors Chatwoot DISTINCT_COUNT_EVENTS. // These events require COUNT(DISTINCT conversation_id) instead of simple SUM(count). var DistinctCountEvents = []string{ "conversation_bot_handoff", } // BackfillDate performs backfill for a single account on a single date. // 1. Delete existing rollups for that date // 2. Aggregate raw events into rollup rows // 3. Bulk insert rollup rows func (s *ReportingBackfillService) BackfillDate(ctx context.Context, accountID uint, date time.Time) error { // Step 1: Delete existing rollups for this account+date if err := s.rollupRepo.DeleteByAccountAndDate(ctx, accountID, date); err != nil { applogger.L().Errorf("BackfillDate delete existing rollups: %v", err) return err } // Step 2: Determine UTC boundaries for the date // TODO: Use account.reporting_timezone for proper TZ conversion (currently UTC) startUTC := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC) endUTC := startUTC.Add(24 * time.Hour) // Step 3: Build rollup rows by aggregating raw events rollupRows, err := s.buildRollupRows(ctx, accountID, date, startUTC, endUTC) if err != nil { applogger.L().Errorf("BackfillDate build rollup rows: %v", err) return err } // Step 4: Bulk insert if any rows were produced if len(rollupRows) > 0 { if err := s.rollupRepo.BulkCreate(ctx, rollupRows); err != nil { applogger.L().Errorf("BackfillDate bulk insert: %v", err) return err } } return nil } // BackfillRange performs backfill for a date range (inclusive). func (s *ReportingBackfillService) BackfillRange(ctx context.Context, accountID uint, startDate, endDate time.Time) error { for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) { if err := s.BackfillDate(ctx, accountID, d); err != nil { applogger.L().Errorf("BackfillRange date=%s: %v", d.Format("2006-01-02"), err) // Continue with next date rather than failing the entire range } } return nil } // RollupAggregate represents a grouped aggregate from raw events. type RollupAggregate struct { DimensionType model.DimensionType DimensionID uint Metric model.RollupMetric Count int64 SumValue float64 SumBizHours float64 } func (s *ReportingBackfillService) buildRollupRows(ctx context.Context, accountID uint, date time.Time, startUTC, endUTC time.Time) ([]model.ReportingEventsRollup, error) { var rollupRows []model.ReportingEventsRollup // For each dimension, aggregate events for _, dim := range BackfillDimensions { aggregates, err := s.aggregateForDimension(ctx, accountID, dim, startUTC, endUTC) if err != nil { return nil, err } for _, agg := range aggregates { // Map raw event metrics to rollup metrics via MetricRegistry rollupMetrics := ExpandEventToRollupMetrics(agg.Metric, agg.Count, agg.SumValue, agg.SumBizHours) for rm, data := range rollupMetrics { rollupRows = append(rollupRows, model.ReportingEventsRollup{ AccountID: accountID, Date: date, DimensionType: agg.DimensionType, DimensionID: agg.DimensionID, Metric: rm, Count: data.Count, SumValue: data.SumValue, SumValueBusinessHours: data.SumBizHours, }) } } } return rollupRows, nil } func (s *ReportingBackfillService) aggregateForDimension(ctx context.Context, accountID uint, dim DimensionSpec, startUTC, endUTC time.Time) ([]RollupAggregate, error) { // Query raw events grouped by the dimension's group column + metric name events, err := s.eventRepo.FindByAccountIDAndTimeRange(ctx, accountID, startUTC, endUTC) if err != nil { return nil, err } // Group events by (dimension_type, dimension_id, metric_name) and aggregate groupMap := make(map[string]*RollupAggregate) for _, event := range events { dimensionID := accountID // account dimension uses account_id if dim.GroupColumn == "user_id" && event.UserID != nil { dimensionID = *event.UserID } else if dim.GroupColumn == "inbox_id" && event.InboxID != nil { dimensionID = *event.InboxID } key := dimKey(dim.Type, dimensionID, event.Name) agg, ok := groupMap[key] if !ok { agg = &RollupAggregate{ DimensionType: dim.Type, DimensionID: dimensionID, Metric: model.RollupMetric(event.Name), Count: 0, SumValue: 0, SumBizHours: 0, } groupMap[key] = agg } // For distinct-count events, we track unique conversation IDs separately // The backfill uses COUNT(DISTINCT conversation_id) at DB level, but here // we approximate by counting each event once per conversation agg.Count++ agg.SumValue += event.Value agg.SumBizHours += event.ValueInBusinessHours } var result []RollupAggregate for _, agg := range groupMap { result = append(result, *agg) } return result, nil } func dimKey(dimType model.DimensionType, dimID uint, metric string) string { return fmt.Sprintf("%s_%d_%s", dimType, dimID, metric) }