// Package service holds business logic. It owns cache orchestration around // the read repos and never touches HTTP/chi or the SQL drivers directly. package service import ( "context" "encoding/json" "time" "go.uber.org/zap" "github.com/dbiz/cdp/data-layer/api/internal/apperr" "github.com/dbiz/cdp/data-layer/api/internal/cache" "github.com/dbiz/cdp/data-layer/api/internal/model" "github.com/dbiz/cdp/data-layer/api/internal/repo" ) type QueryService struct { events *repo.EventRepo analytics *repo.AnalyticsRepo cache *cache.Cache queryTTL time.Duration log *zap.Logger } func NewQueryService(events *repo.EventRepo, analytics *repo.AnalyticsRepo, c *cache.Cache, queryTTL time.Duration, log *zap.Logger) *QueryService { return &QueryService{events: events, analytics: analytics, cache: c, queryTTL: queryTTL, log: log} } // cached wraps `fetch` with the per-workspace Redis cache. Result is JSON- // encoded on miss; CacheHit is set true on hit. func (s *QueryService) cached( ctx context.Context, kind, workspaceID string, params any, fetch func(context.Context) (*model.QueryResult, error), ) (*model.QueryResult, error) { key, err := cache.Key(kind, workspaceID, params) if err != nil { return nil, apperr.Internal(err) } if cached, ok := s.cache.Get(ctx, key); ok { var out model.QueryResult if jerr := json.Unmarshal(cached, &out); jerr == nil { out.CacheHit = true return &out, nil } } start := time.Now() res, err := fetch(ctx) if err != nil { return nil, apperr.Internal(err) } res.DurationMS = time.Since(start).Milliseconds() res.CacheHit = false if b, jerr := json.Marshal(res); jerr == nil { if cerr := s.cache.Set(ctx, key, b, s.queryTTL); cerr != nil { s.log.Warn("cache set", zap.String("key", key), zap.Error(cerr)) } } return res, nil } func (s *QueryService) Events(ctx context.Context, q model.EventQuery) (*model.QueryResult, error) { return s.cached(ctx, "query:events", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) { return s.events.QueryEvents(c, q) }) } func (s *QueryService) Funnel(ctx context.Context, q repo.FunnelQuery) (*model.QueryResult, error) { return s.cached(ctx, "query:funnel", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) { return s.analytics.Funnel(c, q) }) } func (s *QueryService) Retention(ctx context.Context, q repo.RetentionQuery) (*model.QueryResult, error) { return s.cached(ctx, "query:retention", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) { return s.analytics.Retention(c, q) }) } func (s *QueryService) Sessions(ctx context.Context, q repo.SessionQuery) (*model.QueryResult, error) { return s.cached(ctx, "query:session", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) { return s.analytics.Sessions(c, q) }) }