buhapp-backend/internal/handlers/handlers.go
ga d4997e85ef Sprint 2: profile, preferences, location, search
- PUT  /api/v1/me (update name, city, bio, gender, photo)
- GET/PUT /api/v1/me/prefs (drinks, activities, purposes, language)
- PUT  /api/v1/me/location (auto-noise ~300m)
- PUT  /api/v1/me/visibility (hide/show on map)
- GET  /api/v1/search/nearby?lat&lng&radius (haversine, 1h TTL)
- GET  /api/v1/users/:id (public profile, no email/phone)
- migrations 0002: user_preferences, user_locations
2026-08-20 15:49:21 +00:00

326 lines
8.6 KiB
Go

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"
"github.com/buhapp/backend/internal/config"
"github.com/buhapp/backend/internal/consent"
"github.com/buhapp/backend/internal/locations"
"github.com/buhapp/backend/internal/preferences"
"github.com/buhapp/backend/internal/users"
)
type Handlers struct {
Cfg *config.Config
Users *users.Repo
Prefs *preferences.Repo
Locs *locations.Repo
Consent *consent.Repo
Audit *audit.Repo
Auth *auth.Service
}
func New(cfg *config.Config, u *users.Repo, p *preferences.Repo, l *locations.Repo, c *consent.Repo, a *audit.Repo, au *auth.Service) *Handlers {
return &Handlers{Cfg: cfg, Users: u, Prefs: p, Locs: l, Consent: c, Audit: a, Auth: au}
}
func (h *Handlers) GetMe(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
u, err := h.Users.GetByID(c.UserContext(), uid)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if u == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
p, _ := h.Prefs.Get(c.UserContext(), uid)
return c.JSON(fiber.Map{
"user": u,
"prefs": p,
})
}
type updateMeRequest struct {
Name *string `json:"name"`
City *string `json:"city"`
Bio *string `json:"bio"`
Gender *string `json:"gender"`
PhotoURL *string `json:"photo_url"`
}
func (h *Handlers) UpdateMe(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req updateMeRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
// Соберём UPDATE динамически
sets := []string{}
args := []interface{}{}
idx := 1
add := func(col string, val interface{}) {
sets = append(sets, col+"=$"+itoa(idx))
args = append(args, val)
idx++
}
if req.Name != nil {
n := strings.TrimSpace(*req.Name)
if n == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name cannot be empty"})
}
add("name", n)
}
if req.City != nil {
add("city", strings.TrimSpace(*req.City))
}
if req.Bio != nil {
if len(*req.Bio) > 1000 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bio too long (max 1000)"})
}
add("bio", strings.TrimSpace(*req.Bio))
}
if req.Gender != nil {
g := *req.Gender
if g != "m" && g != "f" && g != "o" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "gender must be m, f, o"})
}
add("gender", g)
}
if req.PhotoURL != nil {
add("photo_url", strings.TrimSpace(*req.PhotoURL))
}
if len(sets) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nothing to update"})
}
args = append(args, uid)
sql := "UPDATE users SET "
for i, s := range sets {
if i > 0 {
sql += ", "
}
sql += s
}
sql += ", updated_at=NOW() WHERE id=$" + itoa(idx)
if _, err := h.Users.UpdateRaw(c.UserContext(), sql, args...); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &uid, Action: "user.update",
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
u, _ := h.Users.GetByID(c.UserContext(), uid)
return c.JSON(u)
}
type prefsRequest struct {
Drinks []string `json:"drinks"`
Activities []string `json:"activities"`
Purposes []string `json:"purposes"`
Language string `json:"language"`
}
func (h *Handlers) GetPrefs(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
p, err := h.Prefs.Get(c.UserContext(), uid)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(p)
}
func (h *Handlers) UpdatePrefs(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req prefsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
p := &preferences.Prefs{
UserID: uid,
Drinks: h.Prefs.Validate(req.Drinks, preferences.AllowedDrinks),
Activities: h.Prefs.Validate(req.Activities, preferences.AllowedActivities),
Purposes: h.Prefs.Validate(req.Purposes, preferences.AllowedPurposes),
}
if req.Language != "" {
p.Language = strings.ToLower(strings.TrimSpace(req.Language))
}
if err := h.Prefs.Upsert(c.UserContext(), p); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(p)
}
type locationRequest struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Visible *bool `json:"visible"`
}
func (h *Handlers) UpdateLocation(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req locationRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if req.Lat < -90 || req.Lat > 90 || req.Lng < -180 || req.Lng > 180 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid coordinates"})
}
visible := true
if req.Visible != nil {
visible = *req.Visible
}
loc := &locations.Location{
UserID: uid,
Lat: req.Lat,
Lng: req.Lng,
Visible: visible,
}
if err := h.Locs.Upsert(c.UserContext(), loc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(fiber.Map{
"lat": loc.Lat, "lng": loc.Lng, "visible": loc.Visible,
"updated_at": loc.UpdatedAt,
"note": "coordinates rounded to ~300m for privacy",
})
}
type visibilityRequest struct {
Visible bool `json:"visible"`
}
func (h *Handlers) SetVisibility(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req visibilityRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if err := h.Locs.SetVisible(c.UserContext(), uid, req.Visible); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(fiber.Map{"visible": req.Visible})
}
func (h *Handlers) SearchNearby(c *fiber.Ctx) error {
uid, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
lat := c.QueryFloat("lat", 0)
lng := c.QueryFloat("lng", 0)
radius := c.QueryFloat("radius", 5)
if lat == 0 || lng == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "lat & lng required"})
}
if radius < 0.1 || radius > 100 {
radius = 5
}
results, err := h.Locs.Nearby(c.UserContext(), uid, lat, lng, radius, 200)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(fiber.Map{
"count": len(results),
"results": results,
})
}
func (h *Handlers) GetUserPublic(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad id"})
}
u, err := h.Users.GetByID(c.UserContext(), id)
if err != nil || u == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
p, _ := h.Prefs.Get(c.UserContext(), id)
// Скрываем email/phone в публичном профиле
return c.JSON(fiber.Map{
"id": u.ID,
"name": u.Name,
"city": u.City,
"gender": u.Gender,
"age": age(u.Birthdate),
"bio": u.Bio,
"photo": u.PhotoURL,
"verified": u.IsVerified,
"prefs": p,
})
}
// ----------------- helpers -----------------
func userID(c *fiber.Ctx) (uuid.UUID, error) {
v, err := auth.UserID(c)
if err != nil {
return uuid.Nil, err
}
return uuid.Parse(v)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
if neg {
return "-" + string(digits)
}
return string(digits)
}
func age(birth *time.Time) *int {
if birth == nil {
return nil
}
now := time.Now()
a := now.Year() - birth.Year()
if now.Month() < birth.Month() || (now.Month() == birth.Month() && now.Day() < birth.Day()) {
a--
}
return &a
}