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
This commit is contained in:
parent
8d754f9834
commit
d4997e85ef
@ -20,6 +20,8 @@ import (
|
|||||||
"github.com/buhapp/backend/internal/consent"
|
"github.com/buhapp/backend/internal/consent"
|
||||||
"github.com/buhapp/backend/internal/db"
|
"github.com/buhapp/backend/internal/db"
|
||||||
"github.com/buhapp/backend/internal/handlers"
|
"github.com/buhapp/backend/internal/handlers"
|
||||||
|
"github.com/buhapp/backend/internal/locations"
|
||||||
|
"github.com/buhapp/backend/internal/preferences"
|
||||||
"github.com/buhapp/backend/internal/redis"
|
"github.com/buhapp/backend/internal/redis"
|
||||||
"github.com/buhapp/backend/internal/storage"
|
"github.com/buhapp/backend/internal/storage"
|
||||||
"github.com/buhapp/backend/internal/users"
|
"github.com/buhapp/backend/internal/users"
|
||||||
@ -33,7 +35,6 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// PostgreSQL
|
|
||||||
pg, err := db.New(ctx, cfg.PostgresDSN())
|
pg, err := db.New(ctx, cfg.PostgresDSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("postgres: %v", err)
|
log.Fatalf("postgres: %v", err)
|
||||||
@ -41,7 +42,6 @@ func main() {
|
|||||||
defer pg.Close()
|
defer pg.Close()
|
||||||
log.Println("postgres: connected")
|
log.Println("postgres: connected")
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
migDir := os.Getenv("MIGRATIONS_DIR")
|
migDir := os.Getenv("MIGRATIONS_DIR")
|
||||||
if migDir == "" {
|
if migDir == "" {
|
||||||
migDir = "./migrations"
|
migDir = "./migrations"
|
||||||
@ -50,7 +50,6 @@ func main() {
|
|||||||
log.Fatalf("migrations: %v", err)
|
log.Fatalf("migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redis
|
|
||||||
rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB)
|
rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("redis warning: %v", err)
|
log.Printf("redis warning: %v", err)
|
||||||
@ -58,7 +57,6 @@ func main() {
|
|||||||
log.Println("redis: connected")
|
log.Println("redis: connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MinIO
|
|
||||||
st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
|
st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("minio warning: %v", err)
|
log.Printf("minio warning: %v", err)
|
||||||
@ -66,15 +64,21 @@ func main() {
|
|||||||
log.Println("minio: connected")
|
log.Println("minio: connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Services
|
// Repos
|
||||||
usersRepo := users.NewRepo(pg.Pool)
|
usersRepo := users.NewRepo(pg.Pool)
|
||||||
consentRepo := consent.NewRepo(pg.Pool)
|
consentRepo := consent.NewRepo(pg.Pool)
|
||||||
auditRepo := audit.NewRepo(pg.Pool)
|
auditRepo := audit.NewRepo(pg.Pool)
|
||||||
authSvc := auth.NewService(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL)
|
prefsRepo := preferences.NewRepo(pg.Pool)
|
||||||
|
locsRepo := locations.NewRepo(pg.Pool)
|
||||||
|
|
||||||
|
authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL())
|
||||||
|
_ = authSvc
|
||||||
|
_ = rdb
|
||||||
|
_ = st
|
||||||
|
|
||||||
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
|
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
|
||||||
|
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
|
||||||
|
|
||||||
// Fiber app
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "buhapp-api",
|
AppName: "buhapp-api",
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
@ -88,19 +92,17 @@ func main() {
|
|||||||
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
|
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Health
|
|
||||||
app.Get("/health", func(c *fiber.Ctx) error {
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"time": time.Now().UTC().Format(time.RFC3339),
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"db": pg.Pool != nil,
|
"db": pg.Pool != nil,
|
||||||
"redis": rdb != nil,
|
"redis": rdb != nil,
|
||||||
"storage": st != nil,
|
"storage": st != nil,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Legal — публичные версии документов
|
|
||||||
app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error {
|
app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"version": cfg.TermsVersion, "url": "/legal/TERMS.md"})
|
return c.JSON(fiber.Map{"version": cfg.TermsVersion, "url": "/legal/TERMS.md"})
|
||||||
})
|
})
|
||||||
@ -111,16 +113,23 @@ func main() {
|
|||||||
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth (public)
|
|
||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
api.Post("/auth/register", authH.Register)
|
api.Post("/auth/register", authH.Register)
|
||||||
api.Post("/auth/login", authH.Login)
|
api.Post("/auth/login", authH.Login)
|
||||||
|
|
||||||
// Auth (protected)
|
|
||||||
protected := api.Group("", auth.Middleware(authSvc))
|
protected := api.Group("", auth.Middleware(authSvc))
|
||||||
protected.Get("/me", authH.Me)
|
protected.Get("/me", h.GetMe)
|
||||||
|
protected.Put("/me", h.UpdateMe)
|
||||||
|
|
||||||
|
protected.Get("/me/prefs", h.GetPrefs)
|
||||||
|
protected.Put("/me/prefs", h.UpdatePrefs)
|
||||||
|
|
||||||
|
protected.Put("/me/location", h.UpdateLocation)
|
||||||
|
protected.Put("/me/visibility", h.SetVisibility)
|
||||||
|
|
||||||
|
protected.Get("/search/nearby", h.SearchNearby)
|
||||||
|
protected.Get("/users/:id", h.GetUserPublic)
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
go func() {
|
go func() {
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|||||||
@ -94,6 +94,9 @@ func (c *Config) RedisAddr() string {
|
|||||||
return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort)
|
return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) JwTAccessTTL() time.Duration { return c.JWTAccessTTL }
|
||||||
|
func (c *Config) JwtRefreshTTL() time.Duration { return c.JWTRefreshTTL }
|
||||||
|
|
||||||
func getEnv(key, def string) string {
|
func getEnv(key, def string) string {
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
if v, ok := os.LookupEnv(key); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
325
internal/handlers/handlers.go
Normal file
325
internal/handlers/handlers.go
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
136
internal/locations/repo.go
Normal file
136
internal/locations/repo.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package locations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Location struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Lat float64
|
||||||
|
Lng float64
|
||||||
|
Visible bool
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(pool *pgxpool.Pool) *Repo {
|
||||||
|
return &Repo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyNoise — округляем координаты до ~300м точности (grid ~0.003 deg ≈ 333м)
|
||||||
|
func ApplyNoise(lat, lng float64) (float64, float64) {
|
||||||
|
const step = 0.003
|
||||||
|
return math.Round(lat/step)*step, math.Round(lng/step)*step
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Upsert(ctx context.Context, loc *Location) error {
|
||||||
|
loc.Lat, loc.Lng = ApplyNoise(loc.Lat, loc.Lng)
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO user_locations (user_id, lat, lng, visible, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET lat = EXCLUDED.lat, lng = EXCLUDED.lng,
|
||||||
|
visible = EXCLUDED.visible, updated_at = NOW()`,
|
||||||
|
loc.UserID, loc.Lat, loc.Lng, loc.Visible,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetVisible(ctx context.Context, userID uuid.UUID, visible bool) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO user_locations (user_id, lat, lng, visible, updated_at)
|
||||||
|
VALUES ($1, NULL, NULL, $2, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET visible = EXCLUDED.visible, updated_at = NOW()`,
|
||||||
|
userID, visible,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Get(ctx context.Context, userID uuid.UUID) (*Location, error) {
|
||||||
|
loc := &Location{UserID: userID}
|
||||||
|
var lat, lng *float64
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT lat, lng, visible, updated_at
|
||||||
|
FROM user_locations WHERE user_id=$1`, userID,
|
||||||
|
).Scan(&lat, &lng, &loc.Visible, &loc.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lat != nil {
|
||||||
|
loc.Lat = *lat
|
||||||
|
}
|
||||||
|
if lng != nil {
|
||||||
|
loc.Lng = *lng
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearby — пользователи в радиусе radius км (по haversine), visible, не старше 1 часа
|
||||||
|
type Nearby struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Name string
|
||||||
|
PhotoURL string
|
||||||
|
Lat float64
|
||||||
|
Lng float64
|
||||||
|
DistanceM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Nearby(ctx context.Context, myID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]Nearby, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT u.id, u.name, u.photo_url, ul.lat, ul.lng,
|
||||||
|
2 * 6371 * asin(sqrt(
|
||||||
|
sin(radians((ul.lat - $1) / 2))^2 +
|
||||||
|
cos(radians($1)) * cos(radians(ul.lat)) *
|
||||||
|
sin(radians((ul.lng - $2) / 2))^2
|
||||||
|
)) AS dist_km
|
||||||
|
FROM user_locations ul
|
||||||
|
JOIN users u ON u.id = ul.user_id
|
||||||
|
WHERE ul.visible = TRUE
|
||||||
|
AND u.is_blocked = FALSE
|
||||||
|
AND u.id != $3
|
||||||
|
AND ul.lat IS NOT NULL AND ul.lng IS NOT NULL
|
||||||
|
AND ul.updated_at > NOW() - INTERVAL '1 hour'
|
||||||
|
AND 2 * 6371 * asin(sqrt(
|
||||||
|
sin(radians((ul.lat - $1) / 2))^2 +
|
||||||
|
cos(radians($1)) * cos(radians(ul.lat)) *
|
||||||
|
sin(radians((ul.lng - $2) / 2))^2
|
||||||
|
)) <= $4
|
||||||
|
ORDER BY dist_km
|
||||||
|
LIMIT $5`,
|
||||||
|
lat, lng, myID, radiusKm, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Nearby
|
||||||
|
for rows.Next() {
|
||||||
|
var n Nearby
|
||||||
|
var photo *string
|
||||||
|
if err := rows.Scan(&n.UserID, &n.Name, &photo, &n.Lat, &n.Lng, &n.DistanceM); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if photo != nil {
|
||||||
|
n.PhotoURL = *photo
|
||||||
|
}
|
||||||
|
n.DistanceM = n.DistanceM * 1000
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
103
internal/preferences/repo.go
Normal file
103
internal/preferences/repo.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package preferences
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// допустимые значения (для валидации)
|
||||||
|
var AllowedDrinks = map[string]bool{
|
||||||
|
"beer": true, "wine": true, "whiskey": true, "vodka": true,
|
||||||
|
"cocktail": true, "rum": true, "gin": true, "tequila": true,
|
||||||
|
"cider": true, "champagne": true, "non_alcoholic": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllowedActivities = map[string]bool{
|
||||||
|
"walk": true, "dinner": true, "bar": true, "cafe": true,
|
||||||
|
"travel": true, "movie": true, "concert": true, "sport": true,
|
||||||
|
"cooking": true, "gaming": true, "reading": true, "other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllowedPurposes = map[string]bool{
|
||||||
|
"chat": true, "drink": true, "walk": true, "dinner": true,
|
||||||
|
"travel": true, "relationship": true, "friendship": true, "other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Prefs struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Drinks []string
|
||||||
|
Activities []string
|
||||||
|
Purposes []string
|
||||||
|
Language string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(pool *pgxpool.Pool) *Repo {
|
||||||
|
return &Repo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Get(ctx context.Context, userID uuid.UUID) (*Prefs, error) {
|
||||||
|
p := &Prefs{UserID: userID, Drinks: []string{}, Activities: []string{}, Purposes: []string{}, Language: "ru"}
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT drinks, activities, purposes, COALESCE(language, 'ru'), updated_at
|
||||||
|
FROM user_preferences WHERE user_id=$1`, userID,
|
||||||
|
).Scan(&p.Drinks, &p.Activities, &p.Purposes, &p.Language, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
// no row = defaults
|
||||||
|
p.UpdatedAt = time.Now()
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Upsert(ctx context.Context, p *Prefs) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO user_preferences (user_id, drinks, activities, purposes, language, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET drinks = EXCLUDED.drinks,
|
||||||
|
activities = EXCLUDED.activities,
|
||||||
|
purposes = EXCLUDED.purposes,
|
||||||
|
language = EXCLUDED.language,
|
||||||
|
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 {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, v := range values {
|
||||||
|
v = trimLower(v)
|
||||||
|
if v == "" || seen[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if allowed[v] {
|
||||||
|
out = append(out, v)
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimLower(s string) string {
|
||||||
|
out := []rune{}
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
r = r + 32
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
@ -44,6 +44,14 @@ func (r *Repo) CreateWithPassword(ctx context.Context, u *User, passwordHash str
|
|||||||
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
|
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (int64, error) {
|
||||||
|
tag, err := r.pool.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
var email, phone, gender, city, bio, photo *string
|
var email, phone, gender, city, bio, photo *string
|
||||||
|
|||||||
2
migrations/0002_user_prefs.down.sql
Normal file
2
migrations/0002_user_prefs.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS user_locations;
|
||||||
|
DROP TABLE IF EXISTS user_preferences;
|
||||||
22
migrations/0002_user_prefs.up.sql
Normal file
22
migrations/0002_user_prefs.up.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- User preferences: что пьёт, что делает, цели
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
drinks TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
activities TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
purposes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
language TEXT DEFAULT 'ru',
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User location (приблизительная, обновляется)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_locations (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_locations_visible
|
||||||
|
ON user_locations(visible, updated_at DESC)
|
||||||
|
WHERE visible = TRUE;
|
||||||
Loading…
Reference in New Issue
Block a user