tercul-backend/internal/app/analytics/service.go
Damir Mukimov d50722dad5
Some checks failed
Test / Integration Tests (push) Successful in 4s
Build / Build Binary (push) Failing after 2m9s
Docker Build / Build Docker Image (push) Failing after 2m32s
Test / Unit Tests (push) Failing after 3m12s
Lint / Go Lint (push) Failing after 1m0s
Refactor ID handling to use UUIDs across the application
- Updated database models and repositories to replace uint IDs with UUIDs.
- Modified test fixtures to generate and use UUIDs for authors, translations, users, and works.
- Adjusted mock implementations to align with the new UUID structure.
- Ensured all relevant functions and methods are updated to handle UUIDs correctly.
- Added necessary imports for UUID handling in various files.
2025-12-27 00:33:34 +01:00

373 lines
12 KiB
Go

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)
}