package analytics import ( "context" "errors" "fmt" "sort" "strings" "tercul/internal/domain" "tercul/internal/domain/work" "tercul/internal/jobs/linguistics" "tercul/internal/platform/log" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" ) type Service interface { IncrementWorkViews(ctx context.Context, workID uint) error IncrementWorkLikes(ctx context.Context, workID uint) error IncrementWorkComments(ctx context.Context, workID uint) error IncrementWorkBookmarks(ctx context.Context, workID uint) error IncrementWorkShares(ctx context.Context, workID uint) error IncrementWorkTranslationCount(ctx context.Context, workID uint) error IncrementTranslationViews(ctx context.Context, translationID uint) error IncrementTranslationLikes(ctx context.Context, translationID uint) error IncrementTranslationComments(ctx context.Context, translationID uint) error IncrementTranslationShares(ctx context.Context, translationID uint) error GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) UpdateWorkReadingTime(ctx context.Context, workID uint) error UpdateWorkComplexity(ctx context.Context, workID uint) error UpdateWorkSentiment(ctx context.Context, workID uint) error UpdateTranslationReadingTime(ctx context.Context, translationID uint) error UpdateTranslationSentiment(ctx context.Context, translationID uint) error UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error UpdateTrending(ctx context.Context) error GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) } type service struct { repo Repository analysisRepo linguistics.AnalysisRepository translationRepo domain.TranslationRepository workRepo work.WorkRepository sentimentProvider linguistics.SentimentProvider tracer trace.Tracer } func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo work.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 uint) 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 uint) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkLikes") defer span.End() return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) } func (s *service) IncrementWorkComments(ctx context.Context, workID uint) 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 uint) 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 uint) 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 uint) 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 uint) 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 uint) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationLikes") defer span.End() return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) } func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) 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 uint) 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 uint) (*work.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 uint) (*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 uint) 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 uint) 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 uint) 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 uint) 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 uint) 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 uint, 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) ([]*work.Work, error) { ctx, span := s.tracer.Start(ctx, "GetTrendingWorks") defer span.End() return s.repo.GetTrendingWorks(ctx, timePeriod, limit) } 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) }