package sql import ( "context" "fmt" "tercul/internal/domain" "time" "gorm.io/gorm" ) type analyticsRepository struct { db *gorm.DB } func NewAnalyticsRepository(db *gorm.DB) domain.AnalyticsRepository { return &analyticsRepository{db: db} } 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 { 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(&domain.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(&domain.WorkStats{}).Create(initialData).Error } return nil }) } func (r *analyticsRepository) GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error) { var popularWorks []*domain.PopularWork err := r.db.WithContext(ctx). Model(&domain.WorkStats{}). Select("work_id, (views + likes*2 + comments*3 + bookmarks*4) as score"). Order("score desc"). Limit(limit). Find(&popularWorks).Error return popularWorks, err } func (r *analyticsRepository) GetWorkViews(ctx context.Context, workID uint) (int, error) { var stats domain.WorkStats err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&stats).Error if err != nil { if err == gorm.ErrRecordNotFound { return 0, nil } return 0, err } return int(stats.Views), nil } func (r *analyticsRepository) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) { var translations []*domain.Translation err := r.db.WithContext(ctx). Joins("LEFT JOIN translation_stats ON translation_stats.translation_id = translations.id"). Where("translations.translatable_id = ? AND translations.translatable_type = ?", workID, "Work"). Order("translation_stats.views + (translation_stats.likes * 2) DESC"). Limit(limit). Find(&translations).Error if err != nil { return nil, err } return translations, nil } func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { 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 []*domain.Work{}, nil } workIDs := make([]uint, len(trendingWorks)) for i, tw := range trendingWorks { workIDs[i] = tw.EntityID } var works []*domain.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]*domain.Work) for _, work := range works { workMap[work.ID] = work } orderedWorks := make([]*domain.Work, len(workIDs)) for i, id := range workIDs { if work, ok := workMap[id]; ok { orderedWorks[i] = work } } return orderedWorks, err } func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error { 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 domain.WorkStats) error { return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error } func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error } func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { var stats domain.WorkStats err := r.db.WithContext(ctx).Where(domain.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error return &stats, err } func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { 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) { 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 { return r.db.WithContext(ctx).Save(userEngagement).Error } func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error { 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 }) }