From c9a86793ce6a48c1cdba3a392f364b3fc314f7b7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 00:11:28 +0800 Subject: [PATCH] feat(reports): add live rollup upserts --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 15 +++-- .../reporting_events_rollup_repo.go | 24 +++++++ internal/service/analytics_service.go | 5 ++ internal/service/reporting_metric_registry.go | 7 -- internal/service/reporting_rollup_helpers.go | 5 ++ internal/service/reporting_rollup_service.go | 18 ++++++ .../service/reporting_rollup_timezone_test.go | 64 +++++++++++++++++++ 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index a2235228..d37d379f 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 reporting rollup/backfill timezone parity checkpoint, prepared as `feat(reports): align rollup timezones`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align rollup timezones`. -- Latest documentation/tooling checkpoint: this tracker update records P3.43 reporting rollup/backfill timezone parity plus the committed P3.42/P3.41/P3.40 reports baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. +- Current tracking checkpoint: 2026-06-06 reporting live rollup parity checkpoint, prepared as `feat(reports): add live rollup upserts`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): add live rollup upserts`. +- Latest documentation/tooling checkpoint: this tracker update records P3.44 reporting live additive rollup parity plus the committed P3.43/P3.42/P3.41/P3.40 reports baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: reporting rollup/backfill behavior from `reference/chatwoot/app/services/reporting_events/rollup_service.rb`, `backfill_service.rb`, `spec/services/reporting_events/*`, and `Account#reporting_timezone` is aligned for local reporting dates. GoChat now stores/serializes `accounts.reporting_timezone`, skips rollup writes when it is blank or invalid, converts requested report dates to UTC boundaries through the account reporting timezone, queries `reporting_events.created_at` in `[start, end)`, skips nil agent/inbox dimensions, writes count-metric sums as zero, and deduplicates `conversation_bot_handoff` by `conversation_id` per account/agent/inbox dimension. P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. -- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report rollup/data-source drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: live reporting-event rollups from `reference/chatwoot/app/listeners/reporting_event_listener.rb`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry` are aligned for `AnalyticsService.RecordEvent`: after the raw event is persisted, GoChat builds the same account/agent/inbox rollup rows, skips disabled/invalid account reporting timezones, ignores unknown raw metrics, and additively upserts count/sum/business-hours values into the unique rollup row like Chatwoot `upsert_all` `on_duplicate`. P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. - `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox. - Route dump succeeds with `967` registered routes after profile MFA route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -140,7 +140,8 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.43 reporting rollup/backfill timezone parity | Implemented for reused report data freshness paths: GoChat now follows inspected Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting-event service specs, and `Account#reporting_timezone` validation behavior by persisting/serializing `reporting_timezone`, treating blank or invalid values as a rollup-write skip, converting local report dates into UTC windows through the account reporting timezone, querying reporting events by `created_at >= start AND created_at < end`, skipping nil agent/inbox dimensions instead of falling back to the account id, zeroing count-metric sum fields, and counting distinct `conversation_bot_handoff` conversations per account/agent/inbox dimension. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for per-event additive `upsert_all` live rollup wiring, ActiveSupport timezone-name aliases beyond IANA names, data-source rollup read feature flags, or report metric registry drift beyond the inspected rollup/backfill contract. | Focused Reporting rollup/backfill/service tests, focused Analytics/Account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.44 reporting live additive rollup parity | Implemented for live reporting-event freshness: GoChat now follows inspected Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService#upsert_rollups`, and `ReportingEvents::EventMetricRegistry` behavior when `AnalyticsService.RecordEvent` persists a raw event. Valid account `reporting_timezone` gates the live write, event `created_at` determines the local rollup date, account/agent/inbox dimensions are emitted only when present, known event metrics expand to Chatwoot rollup metrics, unknown raw events emit no rollup rows, and conflicts add incoming `count`, `sum_value`, and `sum_value_business_hours` to the existing unique row. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for dispatcher/listener event creation paths that bypass `AnalyticsService.RecordEvent`, Sidekiq retry semantics beyond swallowed live-rollup errors, or report metric registry additions beyond the inspected reference event registry. | Focused Reporting live rollup/repository tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.43 reporting rollup/backfill timezone parity | Implemented for reused report data freshness paths: GoChat now follows inspected Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting-event service specs, and `Account#reporting_timezone` validation behavior by persisting/serializing `reporting_timezone`, treating blank or invalid values as a rollup-write skip, converting local report dates into UTC windows through the account reporting timezone, querying reporting events by `created_at >= start AND created_at < end`, skipping nil agent/inbox dimensions instead of falling back to the account id, zeroing count-metric sum fields, and counting distinct `conversation_bot_handoff` conversations per account/agent/inbox dimension. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport timezone-name aliases beyond IANA names, data-source rollup read feature flags, or report metric registry drift beyond the inspected rollup/backfill contract. | Focused Reporting rollup/backfill/service tests, focused Analytics/Account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.42 API v2 reports timeseries timezone parity | Implemented for reused dashboard chart clients: `GET /api/v2/accounts/:account_id/reports` now follows inspected Chatwoot `ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, and `TimezoneHelper` behavior by honoring frontend `timezone_offset` for bucket boundaries and returned timestamps. Conversation, message, and reporting-event timeseries now seed zero buckets across the requested range, group by local hour/day/week/month/year starts, preserve data totals while redistributing values across timezone-local buckets, and keep summary-report totals timezone-boundary driven by the frontend `since/until` values. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport named timezone/DST drift, exact Groupdate range edge cases, rollup-data-source behavior, or metric-specific builder differences beyond the inspected raw timeseries contract. | Focused Analytics handler/service timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.41 API v2 reports CSV/download parity | Implemented for reused dashboard report download clients: `GET /api/v2/accounts/:account_id/reports/agents`, `/inboxes`, `/labels`, `/teams`, `/conversations_summary`, and `/conversation_traffic` now follow the inspected Chatwoot v2 reports controller, CSV templates, `ReportsHelper`, `HeatmapHelper`, and `reports.js` download actions by returning `text/csv` attachments instead of JSON. Standard download reports emit Chatwoot filenames, reporting-period row, localized English header text from the reference locale, readable duration formatting, and account-scoped agent/inbox/team/label/conversation-summary rows. `conversation_traffic` accepts frontend `days_before` plus `timezone_offset` without requiring `since/until`, emits `conversation_traffic_reports.csv`, a timezone row, and hourly heatmap rows; explicit `since/until` remains compatible for local callers. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for exact metric-builder aggregation drift, ActiveSupport timezone display-name drift, business-hours summary drift, channel display-name drift, or CSV row ordering/body differences beyond the inspected download contract. | Focused Analytics handler CSV tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.40 API v2 reports envelope/date parity | Implemented for reused dashboard reports clients: `GET /api/v2/accounts/:account_id/reports*`, `/summary_reports/*`, and `/live_reports/*` now follow the inspected Chatwoot v2 controllers by accepting Unix-second `since/until` params from `reports.js`/`summaryReports.js`, preserving existing RFC3339 compatibility for local callers, and returning raw JSON arrays/objects for reports, live reports, and summary reports instead of the local `{ success, data }` envelope. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for CSV body column drift, deeper metric-builder shape drift, authorization payload drift, or report CSV download content differences beyond this raw JSON/date-parameter slice. | Focused Analytics/SummaryReport/LiveReport handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -266,6 +267,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(reports): add live rollup upserts` | Advances P3.44 reporting live additive rollup parity by matching Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry`. GoChat now applies rollups immediately after `AnalyticsService.RecordEvent` persists the raw reporting event, computes the local rollup date from the account `reporting_timezone`, emits only known event-registry metrics, and additively upserts unique rollup rows so repeated live events increment existing `count`, `sum_value`, and `sum_value_business_hours`. | `go test ./internal/service ./internal/repository -run 'Reporting.*Rollup\|Backfill\|Analytics\|ReportingEventsRollup' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.44 moves to Review for current live-rollup evidence; continue Phase 2/3 drift audit for event-listener paths bypassing `AnalyticsService.RecordEvent`, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align rollup timezones` | Advances P3.43 reporting rollup/backfill timezone parity by matching Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting event rollup/backfill specs, `ReportingEvents::EventMetricRegistry`, and `Account#reporting_timezone`. GoChat now adds account `reporting_timezone` storage/serialization, skips blank/invalid rollup writes, computes UTC windows from local reporting dates, uses `reporting_events.created_at` half-open ranges, skips nil agent/inbox dimensions, zeroes count-metric sum columns, and deduplicates `conversation_bot_handoff` by distinct conversation per dimension. | `go test ./internal/service ./internal/handler/api/v1 -run 'Reporting.*Rollup\|Backfill\|Analytics\|Account' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.43 moves to Review for current rollup/backfill timezone evidence; continue Phase 2/3 drift audit for per-event additive rollup wiring, ActiveSupport timezone alias depth, rollup data-source feature flags, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align v2 timeseries timezones` | Advances P3.42 API v2 reports timeseries timezone parity by matching Chatwoot `Api::V2::Accounts::ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, `TimezoneHelper`, `DateRangeHelper`, controller timezone specs, and reused dashboard `reports.js`. GoChat now parses frontend `timezone_offset` on `/reports`, seeds zero buckets across the range, groups conversation/message/reporting-event timeseries by local hour/day/week/month/year starts, and returns timestamps for the local bucket boundary so timezone offsets redistribute values while preserving totals. | `go test ./internal/handler/api/v1 ./internal/service -run 'Analytics\|Timeseries' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.42 moves to Review for current timeseries timezone evidence; continue Phase 2/3 drift audit for Groupdate/DST/rollup builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align v2 csv downloads` | Advances P3.41 API v2 reports CSV/download parity by matching Chatwoot `Api::V2::Accounts::ReportsController`, CSV templates `agents/inboxes/labels/teams/conversations_summary/conversation_traffic`, `ReportsHelper`, `HeatmapHelper`, locale CSV labels, and reused dashboard `reports.js` download actions. GoChat now returns `text/csv` attachments for report downloads, emits Chatwoot filenames and headers, keeps Unix-second `since/until` report period support, formats durations like Chatwoot's time presenter, and lets `conversation_traffic` run from frontend `days_before`/`timezone_offset` without requiring `since/until`. | `go test ./internal/handler/api/v1 -run Analytics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.41 moves to Review for current CSV/download evidence; continue Phase 2/3 drift audit for metric-builder/timezone/body-depth drift, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2521,3 +2523,4 @@ Verification milestone gates: - 2026-06-06: P3.41 API v2 reports CSV/download checkpoint prepared as `feat(reports): align v2 csv downloads`; audited Chatwoot v2 reports CSV actions, templates, ReportsHelper/HeatmapHelper, locale headers, and reused dashboard report download clients. GoChat now returns `text/csv` attachments for agents/inboxes/labels/teams/conversations_summary/conversation_traffic, emits Chatwoot filenames/reporting-period/header rows, renders readable durations, and accepts `conversation_traffic` frontend `days_before`/`timezone_offset` without requiring `since/until`. Focused Analytics CSV tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. - 2026-06-06: P3.42 API v2 reports timeseries timezone checkpoint prepared as `feat(reports): align v2 timeseries timezones`; audited Chatwoot report index controller, Timeseries builder, RawDataSource, TimezoneHelper, DateRangeHelper, controller specs, and reused dashboard reports client. GoChat `/reports` timeseries now honors frontend `timezone_offset` for local hour/day/week/month/year bucket starts, returns local bucket-boundary timestamps, and seeds zero-value buckets across the requested range while preserving totals. Focused Analytics/Timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. - 2026-06-06: P3.43 reporting rollup/backfill timezone checkpoint prepared as `feat(reports): align rollup timezones`; audited Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, rollup/backfill specs, event metric registry, and `Account#reporting_timezone`. GoChat now stores and serializes `reporting_timezone`, skips blank/invalid rollups, computes local-date UTC windows through account reporting timezone, filters reporting events by `created_at` half-open ranges, skips nil agent/inbox dimensions, preserves zero sum fields for count metrics, and deduplicates bot handoffs by distinct conversation per account/agent/inbox. Focused reporting rollup/backfill/service and analytics/account handler tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. +- 2026-06-06: P3.44 reporting live additive rollup checkpoint prepared as `feat(reports): add live rollup upserts`; audited Chatwoot reporting event listener safe-rollup path, rollup service additive `upsert_all`, and event metric registry. GoChat now applies live rollups from `AnalyticsService.RecordEvent` after raw event persistence, uses account reporting timezone for the event-created local date, ignores unknown raw metrics, and additively upserts existing rollup rows instead of waiting for lazy daily recompute. Focused reporting live rollup/repository tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. diff --git a/internal/repository/reporting_events_rollup_repo.go b/internal/repository/reporting_events_rollup_repo.go index bfa218a8..9e157f71 100644 --- a/internal/repository/reporting_events_rollup_repo.go +++ b/internal/repository/reporting_events_rollup_repo.go @@ -6,6 +6,7 @@ import ( "github.com/gochat/gochat/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ReportingEventsRollupRepo provides data access for ReportingEventsRollup. @@ -77,6 +78,29 @@ func (r *ReportingEventsRollupRepo) BulkCreate(ctx context.Context, rollups []mo return r.db.WithContext(ctx).CreateInBatches(rollups, 100).Error } +// AdditiveUpsert inserts rollups or adds incoming values to existing rows. +// Mirrors Chatwoot ReportingEvents::RollupService upsert_all on_duplicate SQL. +func (r *ReportingEventsRollupRepo) AdditiveUpsert(ctx context.Context, rollups []model.ReportingEventsRollup) error { + if len(rollups) == 0 { + return nil + } + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "account_id"}, + {Name: "date"}, + {Name: "dimension_type"}, + {Name: "dimension_id"}, + {Name: "metric"}, + }, + DoUpdates: clause.Assignments(map[string]interface{}{ + "count": gorm.Expr("reporting_events_rollups.count + excluded.count"), + "sum_value": gorm.Expr("reporting_events_rollups.sum_value + excluded.sum_value"), + "sum_value_business_hours": gorm.Expr("reporting_events_rollups.sum_value_business_hours + excluded.sum_value_business_hours"), + "updated_at": gorm.Expr("excluded.updated_at"), + }), + }).CreateInBatches(rollups, 100).Error +} + // FindByAccountAndDateRangeWithDimension retrieves rollups with dimension filters. func (r *ReportingEventsRollupRepo) FindByAccountAndDateRangeWithDimension(ctx context.Context, accountID uint, since, until time.Time, dimType model.DimensionType, dimID uint) ([]model.ReportingEventsRollup, error) { var rollups []model.ReportingEventsRollup diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 1baa33db..aa4851e9 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -249,6 +249,11 @@ func (s *AnalyticsService) RecordEvent(ctx context.Context, event *model.Reporti applogger.L().Errorf("RecordEvent: %v", err) return err } + if s.rollups != nil { + if err := s.rollups.RollupEvent(ctx, event); err != nil { + applogger.L().Errorf("RecordEvent rollup: %v", err) + } + } return nil } diff --git a/internal/service/reporting_metric_registry.go b/internal/service/reporting_metric_registry.go index 853a2af2..ed59162f 100644 --- a/internal/service/reporting_metric_registry.go +++ b/internal/service/reporting_metric_registry.go @@ -67,13 +67,6 @@ func ExpandEventToRollupMetrics(rawMetric model.RollupMetric, count int64, sumVa SumBizHours: 0, } - default: - // Unknown metric — still record it as a direct rollup - result[rawMetric] = RollupMetricData{ - Count: count, - SumValue: sumValue, - SumBizHours: sumBizHours, - } } return result diff --git a/internal/service/reporting_rollup_helpers.go b/internal/service/reporting_rollup_helpers.go index 70258e5f..9a251b17 100644 --- a/internal/service/reporting_rollup_helpers.go +++ b/internal/service/reporting_rollup_helpers.go @@ -47,6 +47,11 @@ func utcBoundariesForReportingDate(date time.Time, loc *time.Location) (time.Tim return startUTC, endUTC, rollupDate } +func rollupDateForReportingTime(value time.Time, loc *time.Location) time.Time { + localTime := value.In(loc) + return time.Date(localTime.Year(), localTime.Month(), localTime.Day(), 0, 0, 0, 0, time.UTC) +} + func dimensionIDForEvent(accountID uint, event model.ReportingEvent, dim DimensionSpec) (uint, bool) { switch dim.GroupColumn { case "": diff --git a/internal/service/reporting_rollup_service.go b/internal/service/reporting_rollup_service.go index 1c9ac4e7..22ad9f86 100644 --- a/internal/service/reporting_rollup_service.go +++ b/internal/service/reporting_rollup_service.go @@ -26,6 +26,24 @@ func NewReportingRollupService(eventRepo *repository.ReportingEventRepo, rollupR return &ReportingRollupService{eventRepo: eventRepo, rollupRepo: rollupRepo} } +// RollupEvent additively applies a freshly saved reporting event to daily rollups. +// Chatwoot does this from ReportingEventListener#safe_rollup after the raw event is saved. +func (s *ReportingRollupService) RollupEvent(ctx context.Context, event *model.ReportingEvent) error { + if event == nil || s == nil || s.rollupRepo == nil { + return nil + } + loc, enabled, err := accountReportingLocation(ctx, reportingServiceDB(s.eventRepo, s.rollupRepo), event.AccountID) + if err != nil { + return err + } + if !enabled { + return nil + } + rollupDate := rollupDateForReportingTime(event.CreatedAt, loc) + rows := s.buildRollupFromEvents(event.AccountID, rollupDate, []model.ReportingEvent{*event}) + return s.rollupRepo.AdditiveUpsert(ctx, rows) +} + // ComputeDailyRollup computes rollup for a specific account and date. func (s *ReportingRollupService) ComputeDailyRollup(ctx context.Context, accountID uint, date time.Time) error { loc, enabled, err := accountReportingLocation(ctx, reportingServiceDB(s.eventRepo, s.rollupRepo), accountID) diff --git a/internal/service/reporting_rollup_timezone_test.go b/internal/service/reporting_rollup_timezone_test.go index bd7581f7..08edda07 100644 --- a/internal/service/reporting_rollup_timezone_test.go +++ b/internal/service/reporting_rollup_timezone_test.go @@ -108,6 +108,70 @@ func TestReportingRollupUsesReportingTimezoneCreatedAtBoundaries(t *testing.T) { assert.Equal(t, 0.0, resolutionsCount.SumValue) } +func TestAnalyticsRecordEventAppliesAdditiveLiveRollup(t *testing.T) { + db, _, _ := setupReportingRollupTZTest(t) + account := createReportingAccount(t, db, "America/New_York") + user, inbox, conversation := createReportingActorRows(t, db, account.ID) + rollupDate := time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC) + require.NoError(t, db.Create(&model.ReportingEventsRollup{ + AccountID: account.ID, + Date: rollupDate, + DimensionType: model.DimensionAccount, + DimensionID: account.ID, + Metric: model.MetricResolutionTime, + Count: 7, + SumValue: 700, + SumValueBusinessHours: 350, + }).Error) + + svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db)) + createdAt := time.Date(2026, 2, 12, 4, 0, 0, 0, time.UTC) + event := &model.ReportingEvent{ + Base: model.Base{CreatedAt: createdAt}, + AccountID: account.ID, + Name: "conversation_resolved", + Value: 100, + ValueInBusinessHours: 50, + UserID: &user.ID, + InboxID: &inbox.ID, + ConversationID: &conversation.ID, + EventStartTime: createdAt.Add(-time.Hour), + EventEndTime: createdAt, + } + require.NoError(t, svc.RecordEvent(context.Background(), event)) + + var rawCount int64 + require.NoError(t, db.Model(&model.ReportingEvent{}).Where("account_id = ? AND name = ?", account.ID, "conversation_resolved").Count(&rawCount).Error) + assert.Equal(t, int64(1), rawCount) + + var resolutionTime model.ReportingEventsRollup + require.NoError(t, db.Where("account_id = ? AND date = ? AND dimension_type = ? AND dimension_id = ? AND metric = ?", account.ID, rollupDate, model.DimensionAccount, account.ID, model.MetricResolutionTime).First(&resolutionTime).Error) + assert.Equal(t, int64(8), resolutionTime.Count) + assert.Equal(t, 800.0, resolutionTime.SumValue) + assert.Equal(t, 400.0, resolutionTime.SumValueBusinessHours) + + var resolutionsCount model.ReportingEventsRollup + require.NoError(t, db.Where("account_id = ? AND date = ? AND dimension_type = ? AND dimension_id = ? AND metric = ?", account.ID, rollupDate, model.DimensionAccount, account.ID, model.MetricResolutionsCount).First(&resolutionsCount).Error) + assert.Equal(t, int64(1), resolutionsCount.Count) + assert.Equal(t, 0.0, resolutionsCount.SumValue) + + var agentRows int64 + require.NoError(t, db.Model(&model.ReportingEventsRollup{}).Where("account_id = ? AND date = ? AND dimension_type = ? AND dimension_id = ?", account.ID, rollupDate, model.DimensionAgent, user.ID).Count(&agentRows).Error) + assert.Equal(t, int64(2), agentRows) +} + +func TestReportingRollupIgnoresUnknownEventMetrics(t *testing.T) { + db, rollups, _ := setupReportingRollupTZTest(t) + account := createReportingAccount(t, db, "America/New_York") + createReportingEvent(t, db, account.ID, "conversation_opened", time.Date(2026, 2, 11, 15, 0, 0, 0, time.UTC), nil, nil, nil) + + require.NoError(t, rollups.ComputeDailyRollup(context.Background(), account.ID, time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC))) + + var count int64 + require.NoError(t, db.Model(&model.ReportingEventsRollup{}).Where("account_id = ?", account.ID).Count(&count).Error) + assert.Equal(t, int64(0), count) +} + func TestReportingRollupSkipsNilAgentAndInboxDimensions(t *testing.T) { db, rollups, _ := setupReportingRollupTZTest(t) account := createReportingAccount(t, db, "America/New_York")