diff --git a/cmd/server/main.go b/cmd/server/main.go index 52fc75d..4e83a12 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 1cc6b2f..b557602 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..8b0ae2c --- /dev/null +++ b/internal/handlers/handlers.go @@ -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 +} diff --git a/internal/locations/repo.go b/internal/locations/repo.go new file mode 100644 index 0000000..7f13a0a --- /dev/null +++ b/internal/locations/repo.go @@ -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") diff --git a/internal/preferences/repo.go b/internal/preferences/repo.go new file mode 100644 index 0000000..18c8441 --- /dev/null +++ b/internal/preferences/repo.go @@ -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) +} diff --git a/internal/users/repo.go b/internal/users/repo.go index cb6d312..a46ab08 100644 --- a/internal/users/repo.go +++ b/internal/users/repo.go @@ -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 diff --git a/migrations/0002_user_prefs.down.sql b/migrations/0002_user_prefs.down.sql new file mode 100644 index 0000000..b73deb6 --- /dev/null +++ b/migrations/0002_user_prefs.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_locations; +DROP TABLE IF EXISTS user_preferences; diff --git a/migrations/0002_user_prefs.up.sql b/migrations/0002_user_prefs.up.sql new file mode 100644 index 0000000..d36f056 --- /dev/null +++ b/migrations/0002_user_prefs.up.sql @@ -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;