mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
This commit implements a robust, production-ready analytics system using an event-driven architecture with Redis and `asynq`. Key changes: - Event-Driven Architecture: Instead of synchronous database updates, analytics events (e.g., views, likes, comments) are now published to a Redis queue. This improves API response times and decouples the analytics system from the main application flow. - Background Worker: A new worker process (`cmd/worker`) has been created to consume events from the queue and update the analytics counters in the database. - View Counting: Implemented the missing view counting feature for both works and translations. - New Analytics Query: Added a `popularTranslations` GraphQL query to demonstrate how to use the collected analytics data. - Testing: Added unit tests for the new event publisher and integration tests for the analytics worker. Known Issue: The integration tests for the analytics worker (`AnalyticsWorkerSuite`) and the GraphQL API (`GraphQLIntegrationSuite`) are currently failing due to the lack of a Redis service in the test environment. The tests are written and are expected to pass in an environment where Redis is available on `localhost:6379`, as configured in the CI pipeline.
307 lines
9.3 KiB
Go
307 lines
9.3 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"tercul/internal/domain"
|
|
"tercul/internal/jobs/linguistics"
|
|
"tercul/internal/platform/log"
|
|
"time"
|
|
)
|
|
|
|
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) (*domain.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) ([]*domain.Work, error)
|
|
GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error)
|
|
}
|
|
|
|
type service struct {
|
|
repo domain.AnalyticsRepository
|
|
analysisRepo linguistics.AnalysisRepository
|
|
translationRepo domain.TranslationRepository
|
|
workRepo domain.WorkRepository
|
|
sentimentProvider linguistics.SentimentProvider
|
|
}
|
|
|
|
func NewService(repo domain.AnalyticsRepository, 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,
|
|
}
|
|
}
|
|
|
|
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
|
|
}
|
|
|
|
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
|
|
return s.repo.GetOrCreateWorkStats(ctx, workID)
|
|
}
|
|
|
|
func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
|
|
return s.repo.GetOrCreateTranslationStats(ctx, translationID)
|
|
}
|
|
|
|
func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
|
|
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 {
|
|
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
|
|
if err != nil {
|
|
log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err))
|
|
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 {
|
|
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID)
|
|
if err != nil {
|
|
log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err))
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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) {
|
|
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
|
|
}
|
|
|
|
func (s *service) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) {
|
|
return s.repo.GetPopularTranslations(ctx, workID, limit)
|
|
}
|
|
|
|
func (s *service) UpdateTrending(ctx context.Context) error {
|
|
log.LogInfo("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 _, work := range works {
|
|
stats, err := s.repo.GetOrCreateWorkStats(ctx, work.ID)
|
|
if err != nil {
|
|
log.LogWarn("failed to get work stats", log.F("workID", work.ID), log.F("error", err))
|
|
continue
|
|
}
|
|
|
|
score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3)
|
|
|
|
trendingWorks = append(trendingWorks, &domain.Trending{
|
|
EntityType: "Work",
|
|
EntityID: work.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)
|
|
}
|