- POST /api/v1/reviews (one per chat_id, UNIQUE) - GET /api/v1/users/:id/reviews (public list) - GET /api/v1/users/:id/stats (rating_avg, count) - migration 0004: reviews (rating 1-5, anonymous flag), user_stats aggregate - only chat participants can review; reviewed_id is other party
152 lines
3.9 KiB
Go
152 lines
3.9 KiB
Go
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
|
||
} |