package reviews import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Review struct { ID uuid.UUID ReviewerID uuid.UUID ReviewedID uuid.UUID ChatID uuid.UUID Rating int Body string Anonymous bool CreatedAt time.Time } type Stats struct { UserID uuid.UUID RatingAvg float64 RatingCount int ReviewsLeft int LastActiveAt *time.Time } type Repo struct { pool *pgxpool.Pool } func NewRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } // Create — создать отзыв. Один отзыв на chat_id (UNIQUE). // Также обновит агрегаты в user_stats. func (r *Repo) Create(ctx context.Context, rev *Review) error { tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var exists bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM chats WHERE id=$1 AND (user_a=$2 OR user_b=$2))`, rev.ChatID, rev.ReviewerID, ).Scan(&exists) if err != nil { return err } if !exists { return errors.New("chat not found or not a participant") } var reviewer, reviewed *string err = tx.QueryRow(ctx, ` INSERT INTO reviews (reviewer_id, reviewed_id, chat_id, rating, body, anonymous) VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6) RETURNING id, created_at, reviewer_id::text, reviewed_id::text`, rev.ReviewerID, rev.ReviewedID, rev.ChatID, rev.Rating, rev.Body, rev.Anonymous, ).Scan(&rev.ID, &rev.CreatedAt, &reviewer, &reviewed) if err != nil { return err } // upsert stats для reviewed _, err = tx.Exec(ctx, ` INSERT INTO user_stats (user_id, rating_avg, rating_count, last_active_at) VALUES ($1, $2, 1, NOW()) ON CONFLICT (user_id) DO UPDATE SET rating_avg = ((user_stats.rating_avg * user_stats.rating_count) + EXCLUDED.rating_avg) / (user_stats.rating_count + 1), rating_count = user_stats.rating_count + 1, last_active_at = NOW()`, rev.ReviewedID, float64(rev.Rating), ) if err != nil { return err } // bump reviews_left для reviewer _, err = tx.Exec(ctx, ` INSERT INTO user_stats (user_id, reviews_left, last_active_at) VALUES ($1, 1, NOW()) ON CONFLICT (user_id) DO UPDATE SET reviews_left = user_stats.reviews_left + 1, last_active_at = NOW()`, rev.ReviewerID, ) if err != nil { return err } return tx.Commit(ctx) } // ListForUser — публичные отзывы о пользователе func (r *Repo) ListForUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Review, error) { if limit <= 0 || limit > 100 { limit = 20 } rows, err := r.pool.Query(ctx, ` SELECT id, reviewer_id, reviewed_id, chat_id, rating, COALESCE(body, ''), anonymous, created_at FROM reviews WHERE reviewed_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, userID, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []Review for rows.Next() { var x Review if err := rows.Scan(&x.ID, &x.ReviewerID, &x.ReviewedID, &x.ChatID, &x.Rating, &x.Body, &x.Anonymous, &x.CreatedAt); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } func (r *Repo) GetStats(ctx context.Context, userID uuid.UUID) (*Stats, error) { s := &Stats{UserID: userID} err := r.pool.QueryRow(ctx, ` SELECT rating_avg, rating_count, reviews_left, last_active_at FROM user_stats WHERE user_id=$1`, userID, ).Scan(&s.RatingAvg, &s.RatingCount, &s.ReviewsLeft, &s.LastActiveAt) if errors.Is(err, pgx.ErrNoRows) { return s, nil } if err != nil { return nil, err } return s, nil } // HasReviewed — оставлял ли reviewer уже отзыв на этот чат func (r *Repo) HasReviewed(ctx context.Context, chatID, reviewerID uuid.UUID) (bool, error) { var ok bool err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM reviews WHERE chat_id=$1 AND reviewer_id=$2)`, chatID, reviewerID, ).Scan(&ok) return ok, err }