Sprint 5: reviews and ratings
- 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
This commit is contained in:
parent
3fe6930af2
commit
1eb7af6bac
@ -6,8 +6,10 @@ WORKDIR /app
|
||||
# dependencies
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || go mod tidy
|
||||
COPY go.mod ./
|
||||
COPY go.sum* ./
|
||||
RUN go mod download || true
|
||||
RUN go mod tidy
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import (
|
||||
"github.com/buhapp/backend/internal/locations"
|
||||
"github.com/buhapp/backend/internal/preferences"
|
||||
"github.com/buhapp/backend/internal/redis"
|
||||
"github.com/buhapp/backend/internal/reviews"
|
||||
"github.com/buhapp/backend/internal/storage"
|
||||
"github.com/buhapp/backend/internal/users"
|
||||
"github.com/buhapp/backend/internal/ws"
|
||||
@ -72,6 +73,7 @@ func main() {
|
||||
prefsRepo := preferences.NewRepo(pg.Pool)
|
||||
locsRepo := locations.NewRepo(pg.Pool)
|
||||
chatRepo := chat.NewRepo(pg.Pool)
|
||||
reviewRepo := reviews.NewRepo(pg.Pool)
|
||||
|
||||
authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL())
|
||||
hub := ws.NewHub()
|
||||
@ -79,6 +81,7 @@ func main() {
|
||||
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
|
||||
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
|
||||
chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub)
|
||||
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
|
||||
|
||||
_ = rdb
|
||||
_ = st
|
||||
@ -146,6 +149,11 @@ func main() {
|
||||
|
||||
protected.Post("/reports", chatH.Report)
|
||||
|
||||
// Reviews
|
||||
protected.Post("/reviews", reviewH.Create)
|
||||
protected.Get("/users/:id/reviews", reviewH.ListForUser)
|
||||
protected.Get("/users/:id/stats", reviewH.Stats)
|
||||
|
||||
// WebSocket — авторизация по ?token=
|
||||
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
||||
|
||||
|
||||
@ -42,7 +42,6 @@ func (s *Service) Generate(userID uuid.UUID) (*Tokens, error) {
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
|
||||
Subject: userID.String(),
|
||||
Type: "access",
|
||||
},
|
||||
})
|
||||
accessStr, err := access.SignedString(s.secret)
|
||||
@ -56,7 +55,6 @@ func (s *Service) Generate(userID uuid.UUID) (*Tokens, error) {
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.refreshTTL)),
|
||||
Subject: userID.String(),
|
||||
Type: "refresh",
|
||||
},
|
||||
})
|
||||
refreshStr, err := refresh.SignedString(s.secret)
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/buhapp/backend/internal/audit"
|
||||
"github.com/buhapp/backend/internal/auth"
|
||||
|
||||
127
internal/handlers/reviews.go
Normal file
127
internal/handlers/reviews.go
Normal file
@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/buhapp/backend/internal/audit"
|
||||
"github.com/buhapp/backend/internal/chat"
|
||||
"github.com/buhapp/backend/internal/reviews"
|
||||
)
|
||||
|
||||
type ReviewHandlers struct {
|
||||
Reviews *reviews.Repo
|
||||
Chat *chat.Repo
|
||||
Audit *audit.Repo
|
||||
}
|
||||
|
||||
func NewReviewHandlers(r *reviews.Repo, ch *chat.Repo, a *audit.Repo) *ReviewHandlers {
|
||||
return &ReviewHandlers{Reviews: r, Chat: ch, Audit: a}
|
||||
}
|
||||
|
||||
type createReviewRequest struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Rating int `json:"rating"`
|
||||
Body string `json:"body"`
|
||||
Anonymous bool `json:"anonymous"`
|
||||
}
|
||||
|
||||
func (h *ReviewHandlers) Create(c *fiber.Ctx) error {
|
||||
me, err := userID(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
|
||||
}
|
||||
var req createReviewRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
|
||||
}
|
||||
chatID, err := uuid.Parse(req.ChatID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad chat_id"})
|
||||
}
|
||||
if req.Rating < 1 || req.Rating > 5 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "rating must be 1..5"})
|
||||
}
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if len(body) > 2000 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body too long (max 2000)"})
|
||||
}
|
||||
|
||||
// Получаем чат и определяем reviewed_id
|
||||
ch, err := h.Chat.GetByID(c.UserContext(), chatID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
if ch == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "chat not found"})
|
||||
}
|
||||
reviewed := ch.UserA
|
||||
if reviewed == me {
|
||||
reviewed = ch.UserB
|
||||
}
|
||||
if ch.UserA != me && ch.UserB != me {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "not a chat member"})
|
||||
}
|
||||
|
||||
// Проверим, не оставлял ли уже
|
||||
dup, _ := h.Reviews.HasReviewed(c.UserContext(), chatID, me)
|
||||
if dup {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "review already exists for this chat"})
|
||||
}
|
||||
|
||||
rev := &reviews.Review{
|
||||
ReviewerID: me,
|
||||
ReviewedID: reviewed,
|
||||
ChatID: chatID,
|
||||
Rating: req.Rating,
|
||||
Body: body,
|
||||
Anonymous: req.Anonymous,
|
||||
}
|
||||
if err := h.Reviews.Create(c.UserContext(), rev); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
_ = h.Audit.Log(c.UserContext(), &audit.Event{
|
||||
UserID: &me, Action: "review.create",
|
||||
TargetType: "user", TargetID: reviewed.String(),
|
||||
IP: c.IP(), UserAgent: c.Get("User-Agent"),
|
||||
})
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"id": rev.ID,
|
||||
"rating": rev.Rating,
|
||||
"anonymous": rev.Anonymous,
|
||||
"created_at": rev.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ReviewHandlers) ListForUser(c *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad id"})
|
||||
}
|
||||
limit := c.QueryInt("limit", 20)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
list, err := h.Reviews.ListForUser(c.UserContext(), id, limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"reviews": list,
|
||||
"count": len(list),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ReviewHandlers) Stats(c *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad id"})
|
||||
}
|
||||
st, err := h.Reviews.GetStats(c.UserContext(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
return c.JSON(st)
|
||||
}
|
||||
@ -69,10 +69,7 @@ func (r *Repo) Upsert(ctx context.Context, p *Prefs) error {
|
||||
updated_at = NOW()`,
|
||||
p.UserID, p.Drinks, p.Activities, p.Purposes, p.Language,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Get(ctx, p.UserID)
|
||||
}
|
||||
|
||||
func (r *Repo) Validate(values []string, allowed map[string]bool) []string {
|
||||
|
||||
152
internal/reviews/repo.go
Normal file
152
internal/reviews/repo.go
Normal file
@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
@ -55,11 +55,12 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (
|
||||
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
u := &User{}
|
||||
var email, phone, gender, city, bio, photo *string
|
||||
var birth *time.Time
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, email, phone, name, birthdate, gender, city, bio, photo_url,
|
||||
is_verified, is_blocked, created_at, updated_at, last_seen_at
|
||||
FROM users WHERE id=$1`, id,
|
||||
).Scan(&u.ID, &email, &phone, &u.Name, &u.Birthdate, &gender, &city, &bio, &photo,
|
||||
).Scan(&u.ID, &email, &phone, &u.Name, &birth, &gender, &city, &bio, &photo,
|
||||
&u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
@ -67,24 +68,13 @@ func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if email != nil {
|
||||
u.Email = *email
|
||||
}
|
||||
if phone != nil {
|
||||
u.Phone = *phone
|
||||
}
|
||||
if gender != nil {
|
||||
u.Gender = *gender
|
||||
}
|
||||
if city != nil {
|
||||
u.City = *city
|
||||
}
|
||||
if bio != nil {
|
||||
u.Bio = *bio
|
||||
}
|
||||
if photo != nil {
|
||||
u.PhotoURL = *photo
|
||||
}
|
||||
if email != nil { u.Email = *email }
|
||||
if phone != nil { u.Phone = *phone }
|
||||
if gender != nil { u.Gender = *gender }
|
||||
if city != nil { u.City = *city }
|
||||
if bio != nil { u.Bio = *bio }
|
||||
if photo != nil { u.PhotoURL = *photo }
|
||||
if birth != nil { u.Birthdate = birth }
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@ -92,11 +82,13 @@ func (r *Repo) GetByEmail(ctx context.Context, email string) (*User, string, err
|
||||
u := &User{}
|
||||
var hash string
|
||||
var em *string
|
||||
var birth *time.Time
|
||||
var gender, city, bio, photo *string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, email, password_hash, name, birthdate, gender, city, bio, photo_url,
|
||||
is_verified, is_blocked, created_at, updated_at, last_seen_at
|
||||
FROM users WHERE email=$1`, email,
|
||||
).Scan(&u.ID, &em, &hash, &u.Name, &u.Birthdate, &u.Gender, &u.City, &u.Bio, &u.PhotoURL,
|
||||
).Scan(&u.ID, &em, &hash, &u.Name, &birth, &gender, &city, &bio, &photo,
|
||||
&u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", nil
|
||||
@ -104,8 +96,11 @@ func (r *Repo) GetByEmail(ctx context.Context, email string) (*User, string, err
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if em != nil {
|
||||
u.Email = *em
|
||||
}
|
||||
if em != nil { u.Email = *em }
|
||||
if gender != nil { u.Gender = *gender }
|
||||
if city != nil { u.City = *city }
|
||||
if bio != nil { u.Bio = *bio }
|
||||
if photo != nil { u.PhotoURL = *photo }
|
||||
if birth != nil { u.Birthdate = birth }
|
||||
return u, hash, nil
|
||||
}
|
||||
|
||||
2
migrations/0004_reviews.down.sql
Normal file
2
migrations/0004_reviews.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS user_stats;
|
||||
DROP TABLE IF EXISTS reviews;
|
||||
24
migrations/0004_reviews.up.sql
Normal file
24
migrations/0004_reviews.up.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- Отзывы и рейтинги после встречи
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reviewed_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
body TEXT,
|
||||
anonymous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (reviewer_id, chat_id), -- один отзыв на чат
|
||||
CHECK (reviewer_id <> reviewed_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_reviewed ON reviews(reviewed_id, created_at DESC);
|
||||
|
||||
-- Агрегаты (можно считать по reviews но удобнее держать)
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0,
|
||||
rating_count INTEGER NOT NULL DEFAULT 0,
|
||||
reviews_left INTEGER NOT NULL DEFAULT 0,
|
||||
last_active_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user