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/db"
|
||||
"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/storage"
|
||||
"github.com/buhapp/backend/internal/users"
|
||||
@ -33,7 +35,6 @@ func main() {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// PostgreSQL
|
||||
pg, err := db.New(ctx, cfg.PostgresDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("postgres: %v", err)
|
||||
@ -41,7 +42,6 @@ func main() {
|
||||
defer pg.Close()
|
||||
log.Println("postgres: connected")
|
||||
|
||||
// Run migrations
|
||||
migDir := os.Getenv("MIGRATIONS_DIR")
|
||||
if migDir == "" {
|
||||
migDir = "./migrations"
|
||||
@ -50,7 +50,6 @@ func main() {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
// Redis
|
||||
rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB)
|
||||
if err != nil {
|
||||
log.Printf("redis warning: %v", err)
|
||||
@ -58,7 +57,6 @@ func main() {
|
||||
log.Println("redis: connected")
|
||||
}
|
||||
|
||||
// MinIO
|
||||
st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
|
||||
if err != nil {
|
||||
log.Printf("minio warning: %v", err)
|
||||
@ -66,15 +64,21 @@ func main() {
|
||||
log.Println("minio: connected")
|
||||
}
|
||||
|
||||
// Services
|
||||
// Repos
|
||||
usersRepo := users.NewRepo(pg.Pool)
|
||||
consentRepo := consent.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)
|
||||
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
|
||||
|
||||
// Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "buhapp-api",
|
||||
ReadTimeout: 15 * time.Second,
|
||||
@ -88,19 +92,17 @@ func main() {
|
||||
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
|
||||
}))
|
||||
|
||||
// Health
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"db": pg.Pool != nil,
|
||||
"redis": rdb != nil,
|
||||
"storage": st != nil,
|
||||
})
|
||||
})
|
||||
|
||||
// Legal — публичные версии документов
|
||||
app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error {
|
||||
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"})
|
||||
})
|
||||
|
||||
// Auth (public)
|
||||
api := app.Group("/api/v1")
|
||||
api.Post("/auth/register", authH.Register)
|
||||
api.Post("/auth/login", authH.Login)
|
||||
|
||||
// Auth (protected)
|
||||
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() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
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)
|
||||
}
|
||||
|
||||
func (c *Config) JwTAccessTTL() time.Duration { return c.JWTAccessTTL }
|
||||
func (c *Config) JwtRefreshTTL() time.Duration { return c.JWTRefreshTTL }
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
u := &User{}
|
||||
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