package postgres import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "datarush/internal/results/domain" "datarush/internal/results/repository" "github.com/google/uuid" "github.com/lib/pq" "github.com/redis/go-redis/v9" ) const ( resultCachePrefix = "result:" leaderboardCachePrefix = "leaderboard:" ) type leaderboardCache struct { Results []domain.Result `json:"results"` TotalCount int `json:"total_count"` } type ResultRepository struct { db *sql.DB redisClient *redis.Client cacheEnabled bool } func NewResultRepository(db *sql.DB, redisClient *redis.Client, cacheEnabled bool) repository.ResultRepository { return &ResultRepository{ db: db, redisClient: redisClient, cacheEnabled: cacheEnabled, } } func (r *ResultRepository) cacheKey(id string) string { return resultCachePrefix + id } func (r *ResultRepository) leaderboardCacheKey(competitionID uuid.UUID, limit, offset int) string { return fmt.Sprintf("%s%s:limit:%d:offset:%d", leaderboardCachePrefix, competitionID.String(), limit, offset) } func (r *ResultRepository) invalidateLeaderboardCache(ctx context.Context, competitionID uuid.UUID) { if !r.cacheEnabled { return } pattern := fmt.Sprintf("%s%s:*", leaderboardCachePrefix, competitionID.String()) keys, err := r.redisClient.Keys(ctx, pattern).Result() if err == nil && len(keys) > 0 { r.redisClient.Del(ctx, keys...).Err() } } func (r *ResultRepository) Create(ctx context.Context, result *domain.Result) error { metadataJSON, err := json.Marshal(result.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } query := `INSERT INTO results (id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` _, err = r.db.ExecContext(ctx, query, result.ID, result.CompetitionID, result.UserID, result.Score, result.Rank, result.Status, result.SubmissionID, metadataJSON, result.CreatedAt, result.UpdatedAt, ) if err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { return domain.ErrDuplicateResult } return err } if r.cacheEnabled { r.invalidateLeaderboardCache(ctx, result.CompetitionID) data, err := json.Marshal(result) if err == nil { r.redisClient.Set(ctx, r.cacheKey(result.ID.String()), data, 10*time.Minute).Err() } } return nil } func (r *ResultRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Result, error) { if r.cacheEnabled { val, err := r.redisClient.Get(ctx, r.cacheKey(id.String())).Result() if err == nil { var result domain.Result if json.Unmarshal([]byte(val), &result) == nil { return &result, nil } } } query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at FROM results WHERE id = $1` row := r.db.QueryRowContext(ctx, query, id) var result domain.Result var metadataJSON []byte err := row.Scan( &result.ID, &result.CompetitionID, &result.UserID, &result.Score, &result.Rank, &result.Status, &result.SubmissionID, &metadataJSON, &result.CreatedAt, &result.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, domain.ErrResultNotFound } return nil, err } if len(metadataJSON) > 0 { if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) } } if r.cacheEnabled { data, err := json.Marshal(&result) if err == nil { r.redisClient.Set(ctx, r.cacheKey(id.String()), data, 10*time.Minute).Err() } } return &result, nil } func (r *ResultRepository) Update(ctx context.Context, result *domain.Result) error { metadataJSON, err := json.Marshal(result.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } query := `UPDATE results SET competition_id = $2, user_id = $3, score = $4, rank = $5, status = $6, submission_id = $7, metadata = $8, updated_at = $9 WHERE id = $1` res, err := r.db.ExecContext(ctx, query, result.ID, result.CompetitionID, result.UserID, result.Score, result.Rank, result.Status, result.SubmissionID, metadataJSON, result.UpdatedAt, ) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return domain.ErrResultNotFound } if r.cacheEnabled { r.invalidateLeaderboardCache(ctx, result.CompetitionID) r.redisClient.Del(ctx, r.cacheKey(result.ID.String())).Err() } return nil } func (r *ResultRepository) Delete(ctx context.Context, id uuid.UUID) error { query := `DELETE FROM results WHERE id = $1` res, err := r.db.ExecContext(ctx, query, id) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return domain.ErrResultNotFound } if r.cacheEnabled { r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() } return nil } func (r *ResultRepository) List(ctx context.Context, opts repository.ListResultsOptions) ([]domain.Result, int, error) { var args []interface{} var whereClauses []string argId := 1 if opts.CompetitionID != nil { whereClauses = append(whereClauses, fmt.Sprintf("competition_id = $%d", argId)) args = append(args, *opts.CompetitionID) argId++ } if opts.UserID != nil { whereClauses = append(whereClauses, fmt.Sprintf("user_id = $%d", argId)) args = append(args, *opts.UserID) argId++ } if opts.Status != nil { whereClauses = append(whereClauses, fmt.Sprintf("status = $%d", argId)) args = append(args, *opts.Status) argId++ } if opts.MinScore != nil { whereClauses = append(whereClauses, fmt.Sprintf("score >= $%d", argId)) args = append(args, *opts.MinScore) argId++ } if opts.MaxScore != nil { whereClauses = append(whereClauses, fmt.Sprintf("score <= $%d", argId)) args = append(args, *opts.MaxScore) argId++ } where := "" if len(whereClauses) > 0 { where = "WHERE " + strings.Join(whereClauses, " AND ") } countQuery := "SELECT COUNT(*) FROM results " + where var total int if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, err } query := fmt.Sprintf( `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at FROM results %s ORDER BY score DESC, created_at ASC LIMIT $%d OFFSET $%d`, where, argId, argId+1, ) args = append(args, opts.PageSize, opts.Page*opts.PageSize) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var results []domain.Result for rows.Next() { var result domain.Result var metadataJSON []byte err := rows.Scan( &result.ID, &result.CompetitionID, &result.UserID, &result.Score, &result.Rank, &result.Status, &result.SubmissionID, &metadataJSON, &result.CreatedAt, &result.UpdatedAt, ) if err != nil { return nil, 0, err } if len(metadataJSON) > 0 { if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { return nil, 0, fmt.Errorf("failed to unmarshal metadata: %w", err) } } results = append(results, result) } return results, total, nil } func (r *ResultRepository) GetByCompetitionAndUser( ctx context.Context, competitionID uuid.UUID, userID uuid.UUID, ) (*domain.Result, error) { query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at FROM results WHERE competition_id = $1 AND user_id = $2` row := r.db.QueryRowContext(ctx, query, competitionID, userID) var result domain.Result var metadataJSON []byte err := row.Scan( &result.ID, &result.CompetitionID, &result.UserID, &result.Score, &result.Rank, &result.Status, &result.SubmissionID, &metadataJSON, &result.CreatedAt, &result.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, domain.ErrResultNotFound } return nil, err } if len(metadataJSON) > 0 { if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) } } return &result, nil } func (r *ResultRepository) GetLeaderboard( ctx context.Context, competitionID uuid.UUID, limit int, offset int, ) ([]domain.Result, int, error) { if r.cacheEnabled { key := r.leaderboardCacheKey(competitionID, limit, offset) val, err := r.redisClient.Get(ctx, key).Result() if err == nil { var cached leaderboardCache if json.Unmarshal([]byte(val), &cached) == nil { return cached.Results, cached.TotalCount, nil } } } countQuery := `SELECT COUNT(*) FROM results WHERE competition_id = $1 AND status = $2` var total int if err := r.db.QueryRowContext(ctx, countQuery, competitionID, domain.ResultStatusCompleted).Scan(&total); err != nil { return nil, 0, err } query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at FROM results WHERE competition_id = $1 AND status = $2 ORDER BY score DESC, created_at ASC LIMIT $3 OFFSET $4` rows, err := r.db.QueryContext(ctx, query, competitionID, domain.ResultStatusCompleted, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var results []domain.Result for rows.Next() { var result domain.Result var metadataJSON []byte err := rows.Scan( &result.ID, &result.CompetitionID, &result.UserID, &result.Score, &result.Rank, &result.Status, &result.SubmissionID, &metadataJSON, &result.CreatedAt, &result.UpdatedAt, ) if err != nil { return nil, 0, err } if len(metadataJSON) > 0 { if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { return nil, 0, fmt.Errorf("failed to unmarshal metadata: %w", err) } } results = append(results, result) } if r.cacheEnabled { key := r.leaderboardCacheKey(competitionID, limit, offset) cacheData := leaderboardCache{Results: results, TotalCount: total} data, err := json.Marshal(cacheData) if err == nil { r.redisClient.Set(ctx, key, data, 1*time.Minute).Err() } } return results, total, nil } func (r *ResultRepository) UpdateStatus( ctx context.Context, id uuid.UUID, status domain.ResultStatus, ) (*domain.Result, error) { query := `UPDATE results SET status = $1, updated_at = $2 WHERE id = $3` now := time.Now() res, err := r.db.ExecContext(ctx, query, status, now, id) if err != nil { return nil, err } rows, err := res.RowsAffected() if err != nil { return nil, err } if rows == 0 { return nil, domain.ErrResultNotFound } if r.cacheEnabled { r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() } result, err := r.Get(ctx, id) if err != nil { return nil, err } if r.cacheEnabled { r.invalidateLeaderboardCache(ctx, result.CompetitionID) } return result, nil } func (r *ResultRepository) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() query := `SELECT id FROM results WHERE competition_id = $1 AND status = $2 ORDER BY score DESC, created_at ASC` rows, err := tx.QueryContext(ctx, query, competitionID, domain.ResultStatusCompleted) if err != nil { return err } var ids []uuid.UUID for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { rows.Close() return err } ids = append(ids, id) } rows.Close() updateQuery := `UPDATE results SET rank = $1, updated_at = $2 WHERE id = $3` now := time.Now() for i, id := range ids { rank := i + 1 if _, err := tx.ExecContext(ctx, updateQuery, rank, now, id); err != nil { return err } if r.cacheEnabled { r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() } } err = tx.Commit() if err != nil { return err } if r.cacheEnabled { r.invalidateLeaderboardCache(ctx, competitionID) } return nil }