package sql import ( "context" "fmt" "tercul/internal/app/analytics" "tercul/internal/domain" "tercul/internal/domain/work" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" ) type analyticsRepository struct { db *gorm.DB tracer trace.Tracer } func NewAnalyticsRepository(db *gorm.DB) analytics.Repository { return &analyticsRepository{ db: db, tracer: otel.Tracer("analytics.repository"), } } var allowedWorkCounterFields = map[string]bool{ "views": true, "likes": true, "comments": true, "bookmarks": true, "shares": true, "translation_count": true, } var allowedTranslationCounterFields = map[string]bool{ "views": true, "likes": true, "comments": true, "shares": true, } func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error { ctx, span := r.tracer.Start(ctx, "IncrementWorkCounter") defer span.End() if !allowedWorkCounterFields[field] { return fmt.Errorf("invalid work counter field: %s", field) } // Using a transaction to ensure atomicity return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // First, try to update the existing record result := tx.Model(&work.WorkStats{}).Where("work_id = ?", workID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) if result.Error != nil { return result.Error } // If no rows were affected, the record does not exist, so create it if result.RowsAffected == 0 { initialData := map[string]interface{}{"work_id": workID, field: value} return tx.Model(&work.WorkStats{}).Create(initialData).Error } return nil }) } func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { ctx, span := r.tracer.Start(ctx, "GetTrendingWorks") defer span.End() var trendingWorks []*domain.Trending err := r.db.WithContext(ctx). Where("entity_type = ? AND time_period = ?", "Work", timePeriod). Order("rank ASC"). Limit(limit). Find(&trendingWorks).Error if err != nil { return nil, err } if len(trendingWorks) == 0 { return []*work.Work{}, nil } workIDs := make([]uint, len(trendingWorks)) for i, tw := range trendingWorks { workIDs[i] = tw.EntityID } var works []*work.Work err = r.db.WithContext(ctx). Where("id IN ?", workIDs). Find(&works).Error // This part is tricky because the order from the IN clause is not guaranteed. // We need to re-order the works based on the trending rank. workMap := make(map[uint]*work.Work) for _, w := range works { workMap[w.ID] = w } orderedWorks := make([]*work.Work, len(workIDs)) for i, id := range workIDs { if w, ok := workMap[id]; ok { orderedWorks[i] = w } } return orderedWorks, err } func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error { ctx, span := r.tracer.Start(ctx, "IncrementTranslationCounter") defer span.End() if !allowedTranslationCounterFields[field] { return fmt.Errorf("invalid translation counter field: %s", field) } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { initialData := map[string]interface{}{"translation_id": translationID, field: value} return tx.Model(&domain.TranslationStats{}).Create(initialData).Error } return nil }) } func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { ctx, span := r.tracer.Start(ctx, "UpdateWorkStats") defer span.End() return r.db.WithContext(ctx).Model(&work.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error } func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { ctx, span := r.tracer.Start(ctx, "UpdateTranslationStats") defer span.End() return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error } func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { ctx, span := r.tracer.Start(ctx, "GetOrCreateWorkStats") defer span.End() var stats work.WorkStats err := r.db.WithContext(ctx).Where(work.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error return &stats, err } func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { ctx, span := r.tracer.Start(ctx, "GetOrCreateTranslationStats") defer span.End() var stats domain.TranslationStats err := r.db.WithContext(ctx).Where(domain.TranslationStats{TranslationID: translationID}).FirstOrCreate(&stats).Error return &stats, err } func (r *analyticsRepository) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { ctx, span := r.tracer.Start(ctx, "GetOrCreateUserEngagement") defer span.End() var engagement domain.UserEngagement err := r.db.WithContext(ctx).Where(domain.UserEngagement{UserID: userID, Date: date}).FirstOrCreate(&engagement).Error return &engagement, err } func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error { ctx, span := r.tracer.Start(ctx, "UpdateUserEngagement") defer span.End() return r.db.WithContext(ctx).Save(userEngagement).Error } func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error { ctx, span := r.tracer.Start(ctx, "UpdateTrendingWorks") defer span.End() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Clear old trending data for this time period if err := tx.Where("time_period = ?", timePeriod).Delete(&domain.Trending{}).Error; err != nil { return fmt.Errorf("failed to delete old trending data: %w", err) } if len(trending) == 0 { return nil } // Insert new trending data if err := tx.Create(trending).Error; err != nil { return fmt.Errorf("failed to insert new trending data: %w", err) } return nil }) }