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:
ga 2026-08-20 15:49:21 +00:00
parent 8d754f9834
commit d4997e85ef
8 changed files with 622 additions and 14 deletions

View File

@ -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)

View File

@ -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

View 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
View 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")

View 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)
}

View File

@ -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

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS user_locations;
DROP TABLE IF EXISTS user_preferences;

View 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;