diff --git a/Dockerfile b/Dockerfile index 9f4b49a..754210f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/cmd/server/main.go b/cmd/server/main.go index 3bb9801..9027c18 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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)) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 9fdfc27..ad643fd 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -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) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8b0ae2c..efb3442 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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" diff --git a/internal/handlers/reviews.go b/internal/handlers/reviews.go new file mode 100644 index 0000000..df1ecf8 --- /dev/null +++ b/internal/handlers/reviews.go @@ -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) +} \ No newline at end of file diff --git a/internal/preferences/repo.go b/internal/preferences/repo.go index 18c8441..3004513 100644 --- a/internal/preferences/repo.go +++ b/internal/preferences/repo.go @@ -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) + return err } func (r *Repo) Validate(values []string, allowed map[string]bool) []string { diff --git a/internal/reviews/repo.go b/internal/reviews/repo.go new file mode 100644 index 0000000..9f5b054 --- /dev/null +++ b/internal/reviews/repo.go @@ -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 +} \ No newline at end of file diff --git a/internal/users/repo.go b/internal/users/repo.go index a46ab08..d7fc5b2 100644 --- a/internal/users/repo.go +++ b/internal/users/repo.go @@ -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 } diff --git a/migrations/0004_reviews.down.sql b/migrations/0004_reviews.down.sql new file mode 100644 index 0000000..86cdade --- /dev/null +++ b/migrations/0004_reviews.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_stats; +DROP TABLE IF EXISTS reviews; \ No newline at end of file diff --git a/migrations/0004_reviews.up.sql b/migrations/0004_reviews.up.sql new file mode 100644 index 0000000..719af29 --- /dev/null +++ b/migrations/0004_reviews.up.sql @@ -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() +); \ No newline at end of file