package analytics import ( "context" "errors" "fmt" "sort" "strings" "tercul/internal/domain" "tercul/internal/jobs/linguistics" "tercul/internal/platform/log" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "github.com/google/uuid" ) type Service interface { IncrementWorkViews(ctx context.Context, workID uuid.UUID) error IncrementWorkLikes(ctx context.Context, workID uuid.UUID) error IncrementWorkComments(ctx context.Context, workID uuid.UUID) error IncrementWorkBookmarks(ctx context.Context, workID uuid.UUID) error IncrementWorkShares(ctx context.Context, workID uuid.UUID) error IncrementWorkTranslationCount(ctx context.Context, workID uuid.UUID) error IncrementTranslationViews(ctx context.Context, translationID uuid.UUID) error IncrementTranslationLikes(ctx context.Context, translationID uuid.UUID) error DecrementWorkLikes(ctx context.Context, workID uuid.UUID) error DecrementTranslationLikes(ctx context.Context, translationID uuid.UUID) error IncrementTranslationComments(ctx context.Context, translationID uuid.UUID) error IncrementTranslationShares(ctx context.Context, translationID uuid.UUID) error GetOrCreateWorkStats(ctx context.Context, workID uuid.UUID) (*domain.WorkStats, error) GetOrCreateTranslationStats(ctx context.Context, translationID uuid.UUID) (*domain.TranslationStats, error) UpdateWorkReadingTime(ctx context.Context, workID uuid.UUID) error UpdateWorkComplexity(ctx context.Context, workID uuid.UUID) error UpdateWorkSentiment(ctx context.Context, workID uuid.UUID) error UpdateTranslationReadingTime(ctx context.Context, translationID uuid.UUID) error UpdateTranslationSentiment(ctx context.Context, translationID uuid.UUID) error UpdateUserEngagement(ctx context.Context, userID uuid.UUID, eventType string) error UpdateTrending(ctx context.Context) error GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) UpdateWorkStats(ctx context.Context, workID uuid.UUID, stats domain.WorkStats) error } type service struct { repo Repository analysisRepo linguistics.AnalysisRepository translationRepo domain.TranslationRepository workRepo domain.WorkRepository sentimentProvider linguistics.SentimentProvider tracer trace.Tracer } func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { return &service{ repo: repo, analysisRepo: analysisRepo, translationRepo: translationRepo, workRepo: workRepo, sentimentProvider: sentimentProvider, tracer: otel.Tracer("analytics.service"), } } func (s *service) IncrementWorkViews(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkViews") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "views", 1) } func (s *service) IncrementWorkLikes(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkLikes") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) } func (s *service) DecrementWorkLikes(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "DecrementWorkLikes") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "likes", -1) } func (s *service) IncrementWorkComments(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkComments") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1) } func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkBookmarks") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1) } func (s *service) IncrementWorkShares(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkShares") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1) } func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkTranslationCount") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1) } func (s *service) IncrementTranslationViews(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationViews") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1) } func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationLikes") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) } func (s *service) DecrementTranslationLikes(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "DecrementTranslationLikes") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", -1) } func (s *service) IncrementTranslationComments(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1) } func (s *service) IncrementTranslationShares(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationShares") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) } func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uuid.UUID) (*domain.WorkStats, error) { ctx, span := s.tracer.Start(ctx, "GetOrCreateWorkStats") defer span.End() return s.repo.GetOrCreateWorkStats(ctx, workID) } func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uuid.UUID) (*domain.TranslationStats, error) { ctx, span := s.tracer.Start(ctx, "GetOrCreateTranslationStats") defer span.End() return s.repo.GetOrCreateTranslationStats(ctx, translationID) } func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "UpdateWorkReadingTime") defer span.End() stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) if err != nil { return err } textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) if err != nil { return err } if textMetadata == nil { return errors.New("text metadata not found") } readingTime := 0 if textMetadata.WordCount > 0 { readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division } stats.ReadingTime = readingTime return s.repo.UpdateWorkStats(ctx, workID, *stats) } func (s *service) UpdateWorkComplexity(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "UpdateWorkComplexity") defer span.End() logger := log.FromContext(ctx).With("workID", workID) stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) if err != nil { return err } _, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) if err != nil { logger.Error(err, "could not get readability score for work") return nil } if readabilityScore == nil { return errors.New("readability score not found") } stats.Complexity = readabilityScore.Score return s.repo.UpdateWorkStats(ctx, workID, *stats) } func (s *service) UpdateWorkSentiment(ctx context.Context, workID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "UpdateWorkSentiment") defer span.End() logger := log.FromContext(ctx).With("workID", workID) stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) if err != nil { return err } _, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID) if err != nil { logger.Error(err, "could not get language analysis for work") return nil } if languageAnalysis == nil { return errors.New("language analysis not found") } sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64) if !ok { return errors.New("sentiment score not found in language analysis") } stats.Sentiment = sentiment return s.repo.UpdateWorkStats(ctx, workID, *stats) } func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "UpdateTranslationReadingTime") defer span.End() stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) if err != nil { return err } translation, err := s.translationRepo.GetByID(ctx, translationID) if err != nil { return err } if translation == nil { return errors.New("translation not found") } wordCount := len(strings.Fields(translation.Content)) readingTime := 0 if wordCount > 0 { readingTime = (wordCount + 199) / 200 // Ceil division } stats.ReadingTime = readingTime return s.repo.UpdateTranslationStats(ctx, translationID, *stats) } func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uuid.UUID) error { ctx, span := s.tracer.Start(ctx, "UpdateTranslationSentiment") defer span.End() stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) if err != nil { return err } translation, err := s.translationRepo.GetByID(ctx, translationID) if err != nil { return err } if translation == nil { return errors.New("translation not found") } sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language) if err != nil { return err } stats.Sentiment = sentiment return s.repo.UpdateTranslationStats(ctx, translationID, *stats) } func (s *service) UpdateUserEngagement(ctx context.Context, userID uuid.UUID, eventType string) error { ctx, span := s.tracer.Start(ctx, "UpdateUserEngagement") defer span.End() today := time.Now().UTC().Truncate(24 * time.Hour) engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today) if err != nil { return err } switch eventType { case "work_read": engagement.WorksRead++ case "comment_made": engagement.CommentsMade++ case "like_given": engagement.LikesGiven++ case "bookmark_made": engagement.BookmarksMade++ case "translation_made": engagement.TranslationsMade++ default: return errors.New("invalid engagement event type") } return s.repo.UpdateUserEngagement(ctx, engagement) } func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { ctx, span := s.tracer.Start(ctx, "GetTrendingWorks") defer span.End() return s.repo.GetTrendingWorks(ctx, timePeriod, limit) } func (s *service) UpdateWorkStats(ctx context.Context, workID uuid.UUID, stats domain.WorkStats) error { ctx, span := s.tracer.Start(ctx, "UpdateWorkStats") defer span.End() return s.repo.UpdateWorkStats(ctx, workID, stats) } func (s *service) UpdateTrending(ctx context.Context) error { ctx, span := s.tracer.Start(ctx, "UpdateTrending") defer span.End() logger := log.FromContext(ctx) logger.Info("Updating trending works") works, err := s.workRepo.ListAll(ctx) if err != nil { return fmt.Errorf("failed to list works: %w", err) } trendingWorks := make([]*domain.Trending, 0, len(works)) for _, aWork := range works { stats, err := s.repo.GetOrCreateWorkStats(ctx, aWork.ID) if err != nil { logger.With("workID", aWork.ID).Error(err, "failed to get work stats") continue } score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3) trendingWorks = append(trendingWorks, &domain.Trending{ EntityType: "Work", EntityID: aWork.ID, Score: score, TimePeriod: "daily", // Hardcoded for now Date: time.Now().UTC(), }) } // Sort by score sort.Slice(trendingWorks, func(i, j int) bool { return trendingWorks[i].Score > trendingWorks[j].Score }) // Get top 10 if len(trendingWorks) > 10 { trendingWorks = trendingWorks[:10] } // Set ranks for i := range trendingWorks { trendingWorks[i].Rank = i + 1 } return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks) }