- Rename module path from github.com/patbritton/sojorn-backend to gitlab.com/patrickbritton3/sojorn/go-backend - Updated 78 references across 41 files - Matches new GitLab repository structure
654 lines
20 KiB
Go
654 lines
20 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
|
)
|
|
|
|
// NeighborhoodHandler manages neighborhood detection, on-demand creation,
|
|
// and user auto-join.
|
|
type NeighborhoodHandler struct {
|
|
pool *pgxpool.Pool
|
|
overpass *services.OverpassService
|
|
}
|
|
|
|
func NewNeighborhoodHandler(pool *pgxpool.Pool) *NeighborhoodHandler {
|
|
return &NeighborhoodHandler{
|
|
pool: pool,
|
|
overpass: services.NewOverpassService(),
|
|
}
|
|
}
|
|
|
|
// Detect finds (or creates) the neighborhood for the given coordinates,
|
|
// creates a group on-demand if needed, and auto-joins the user.
|
|
//
|
|
// GET /neighborhoods/detect?lat=...&long=...
|
|
func (h *NeighborhoodHandler) Detect(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, err := uuid.Parse(userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
lat, err := strconv.ParseFloat(c.Query("lat"), 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "lat required"})
|
|
return
|
|
}
|
|
lng, err := strconv.ParseFloat(c.Query("long"), 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "long required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// ── Step 1: Check if we already have a cached neighborhood seed nearby ──
|
|
seed, err := h.findNearbySeed(ctx, lat, lng, 1500) // 1.5km search radius
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] DB lookup error: %v", err)
|
|
}
|
|
|
|
// ── Step 2: If no cached seed, query Overpass API ───────────────────────
|
|
if seed == nil {
|
|
seed, err = h.detectViaOverpass(ctx, lat, lng)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Overpass error: %v", err)
|
|
// Fall back to a generic neighborhood name from Nominatim
|
|
seed, err = h.fallbackGeneric(ctx, lat, lng)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Fallback error: %v", err)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "could not determine neighborhood"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if seed == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no neighborhood found for this location"})
|
|
return
|
|
}
|
|
|
|
// ── Step 3: Ensure the seed has an associated group ─────────────────────
|
|
isNew := false
|
|
if seed.GroupID == nil {
|
|
groupID, err := h.createNeighborhoodGroup(ctx, seed)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Create group error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create neighborhood group"})
|
|
return
|
|
}
|
|
seed.GroupID = &groupID
|
|
isNew = true
|
|
}
|
|
|
|
// ── Step 4: Auto-join user to the group ─────────────────────────────────
|
|
justJoined, err := h.autoJoin(ctx, *seed.GroupID, userID)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Auto-join error: %v", err)
|
|
}
|
|
|
|
// ── Step 5: Fetch group details for response ────────────────────────────
|
|
var groupName string
|
|
var memberCount int
|
|
h.pool.QueryRow(ctx, `SELECT name, member_count FROM groups WHERE id = $1`, *seed.GroupID).Scan(&groupName, &memberCount)
|
|
|
|
var boardPostCount int
|
|
_ = h.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM board_entries
|
|
WHERE is_active = TRUE
|
|
AND ST_DWithin(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
|
`, seed.Lng, seed.Lat, seed.RadiusMeters).Scan(&boardPostCount)
|
|
|
|
var groupPostCount int
|
|
_ = h.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM group_posts
|
|
WHERE group_id = $1 AND is_deleted = FALSE
|
|
`, *seed.GroupID).Scan(&groupPostCount)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"neighborhood": gin.H{
|
|
"id": seed.ID,
|
|
"name": seed.Name,
|
|
"city": seed.City,
|
|
"state": seed.State,
|
|
"zip_code": seed.ZipCode,
|
|
"country": seed.Country,
|
|
"lat": seed.Lat,
|
|
"lng": seed.Lng,
|
|
"radius_meters": seed.RadiusMeters,
|
|
},
|
|
"group_id": seed.GroupID,
|
|
"group_name": groupName,
|
|
"member_count": memberCount,
|
|
"board_post_count": boardPostCount,
|
|
"group_post_count": groupPostCount,
|
|
"is_new": isNew,
|
|
"just_joined": justJoined,
|
|
})
|
|
}
|
|
|
|
// GetCurrent returns the user's current neighborhood (most recent join).
|
|
// GET /neighborhoods/current
|
|
func (h *NeighborhoodHandler) GetCurrent(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, err := uuid.Parse(userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
var seedID, groupID uuid.UUID
|
|
var name, city, state, zipCode, country, groupName string
|
|
var lat, lng float64
|
|
var radiusMeters, memberCount int
|
|
err = h.pool.QueryRow(ctx, `
|
|
SELECT ns.id, ns.name, ns.city, ns.state, COALESCE(ns.zip_code, ''), ns.country, ns.lat, ns.lng, ns.radius_meters,
|
|
g.id, g.name, g.member_count
|
|
FROM neighborhood_seeds ns
|
|
JOIN groups g ON g.id = ns.group_id
|
|
JOIN group_members gm ON gm.group_id = g.id
|
|
WHERE gm.user_id = $1 AND g.type = 'neighborhood' AND g.is_active = TRUE
|
|
ORDER BY gm.joined_at DESC
|
|
LIMIT 1
|
|
`, userID).Scan(&seedID, &name, &city, &state, &zipCode, &country, &lat, &lng, &radiusMeters,
|
|
&groupID, &groupName, &memberCount)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no neighborhood found"})
|
|
return
|
|
}
|
|
|
|
var boardPostCount int
|
|
_ = h.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM board_entries
|
|
WHERE is_active = TRUE
|
|
AND ST_DWithin(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
|
`, lng, lat, radiusMeters).Scan(&boardPostCount)
|
|
|
|
var groupPostCount int
|
|
_ = h.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM group_posts
|
|
WHERE group_id = $1 AND is_deleted = FALSE
|
|
`, groupID).Scan(&groupPostCount)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"neighborhood": gin.H{
|
|
"id": seedID,
|
|
"name": name,
|
|
"city": city,
|
|
"state": state,
|
|
"zip_code": zipCode,
|
|
"country": country,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius_meters": radiusMeters,
|
|
},
|
|
"group_id": groupID,
|
|
"group_name": groupName,
|
|
"member_count": memberCount,
|
|
"board_post_count": boardPostCount,
|
|
"group_post_count": groupPostCount,
|
|
})
|
|
}
|
|
|
|
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
|
|
// findNearbySeed checks if we already have a cached neighborhood within range.
|
|
func (h *NeighborhoodHandler) findNearbySeed(ctx context.Context, lat, lng float64, radiusMeters int) (*seedRow, error) {
|
|
var s seedRow
|
|
err := h.pool.QueryRow(ctx, `
|
|
SELECT id, name, city, state, COALESCE(zip_code, ''), country, lat, lng, radius_meters, group_id
|
|
FROM neighborhood_seeds
|
|
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
|
ORDER BY ST_Distance(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) ASC
|
|
LIMIT 1
|
|
`, lng, lat, radiusMeters).Scan(
|
|
&s.ID, &s.Name, &s.City, &s.State, &s.ZipCode, &s.Country, &s.Lat, &s.Lng, &s.RadiusMeters, &s.GroupID,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// detectViaOverpass queries the Overpass API and caches the result.
|
|
func (h *NeighborhoodHandler) detectViaOverpass(ctx context.Context, lat, lng float64) (*seedRow, error) {
|
|
nr, err := h.overpass.DetectNeighborhood(ctx, lat, lng)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if nr == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Get city/state/country from Nominatim
|
|
city, state, country, zipCode, err := h.overpass.ReverseGeocodeCity(ctx, nr.Lat, nr.Lng)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Nominatim fallback error: %v", err)
|
|
// Continue with empty city/state — we still have the neighborhood name
|
|
}
|
|
if country == "" {
|
|
country = "US"
|
|
}
|
|
|
|
// Cache the seed in the database
|
|
return h.upsertSeed(ctx, nr.Name, city, state, zipCode, country, nr.Lat, nr.Lng, 1500)
|
|
}
|
|
|
|
// fallbackGeneric uses Nominatim alone to create a generic neighborhood
|
|
// when Overpass returns nothing (rural areas, etc).
|
|
func (h *NeighborhoodHandler) fallbackGeneric(ctx context.Context, lat, lng float64) (*seedRow, error) {
|
|
city, state, country, zipCode, err := h.overpass.ReverseGeocodeCity(ctx, lat, lng)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if city == "" {
|
|
return nil, fmt.Errorf("no city found")
|
|
}
|
|
|
|
// Use the city name as the neighborhood name for rural/suburban areas
|
|
name := city + " Area"
|
|
return h.upsertSeed(ctx, name, city, state, zipCode, country, lat, lng, 5000)
|
|
}
|
|
|
|
// upsertSeed inserts or returns an existing seed.
|
|
func (h *NeighborhoodHandler) upsertSeed(ctx context.Context, name, city, state, zipCode, country string, lat, lng float64, radius int) (*seedRow, error) {
|
|
var s seedRow
|
|
err := h.pool.QueryRow(ctx, `
|
|
INSERT INTO neighborhood_seeds (name, city, state, zip_code, country, lat, lng, radius_meters)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (name, city, state) DO UPDATE SET
|
|
zip_code = EXCLUDED.zip_code,
|
|
country = EXCLUDED.country
|
|
RETURNING id, name, city, state, COALESCE(zip_code, ''), country, lat, lng, radius_meters, group_id
|
|
`, name, city, state, zipCode, country, lat, lng, radius).Scan(
|
|
&s.ID, &s.Name, &s.City, &s.State, &s.ZipCode, &s.Country, &s.Lat, &s.Lng, &s.RadiusMeters, &s.GroupID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert seed: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// createNeighborhoodGroup creates an open (non-encrypted) group for a neighborhood seed.
|
|
func (h *NeighborhoodHandler) createNeighborhoodGroup(ctx context.Context, seed *seedRow) (uuid.UUID, error) {
|
|
tx, err := h.pool.Begin(ctx)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
groupName := fmt.Sprintf("%s — %s", seed.Name, seed.City)
|
|
description := fmt.Sprintf("Neighborhood board for %s in %s, %s", seed.Name, seed.City, seed.State)
|
|
|
|
var groupID uuid.UUID
|
|
var createdAt time.Time
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO groups (name, description, type, privacy, is_encrypted, member_count, key_version, category,
|
|
location_center, radius_meters)
|
|
VALUES ($1, $2, 'neighborhood', 'public', FALSE, 0, 0, 'general',
|
|
ST_SetSRID(ST_MakePoint($3, $4), 4326)::geography, $5)
|
|
RETURNING id, created_at
|
|
`, groupName, description, seed.Lng, seed.Lat, seed.RadiusMeters).Scan(&groupID, &createdAt)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("create group: %w", err)
|
|
}
|
|
|
|
// Link the seed to the group
|
|
_, err = tx.Exec(ctx, `UPDATE neighborhood_seeds SET group_id = $1 WHERE id = $2`, groupID, seed.ID)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("link seed: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
return groupID, nil
|
|
}
|
|
|
|
// autoJoin adds a user to the neighborhood group. Returns true if they were newly added.
|
|
func (h *NeighborhoodHandler) autoJoin(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
|
|
tag, err := h.pool.Exec(ctx, `
|
|
INSERT INTO group_members (group_id, user_id, role)
|
|
VALUES ($1, $2, 'member')
|
|
ON CONFLICT (group_id, user_id) DO NOTHING
|
|
`, groupID, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
justJoined := tag.RowsAffected() > 0
|
|
if justJoined {
|
|
// Update member count
|
|
h.pool.Exec(ctx, `
|
|
UPDATE groups SET member_count = (
|
|
SELECT COUNT(*) FROM group_members WHERE group_id = $1
|
|
) WHERE id = $1
|
|
`, groupID)
|
|
}
|
|
|
|
return justJoined, nil
|
|
}
|
|
|
|
// SearchByZip returns neighborhood seeds matching a ZIP code.
|
|
// GET /neighborhoods/search?zip=55408
|
|
func (h *NeighborhoodHandler) SearchByZip(c *gin.Context) {
|
|
zip := c.Query("zip")
|
|
if zip == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "zip parameter required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
rows, err := h.pool.Query(ctx, `
|
|
SELECT id, name, city, state, COALESCE(zip_code, ''), country, lat, lng, radius_meters
|
|
FROM neighborhood_seeds
|
|
WHERE zip_code = $1
|
|
ORDER BY name
|
|
`, zip)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var seeds []gin.H
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var name, city, state, zipCode, country string
|
|
var lat, lng float64
|
|
var radius int
|
|
if err := rows.Scan(&id, &name, &city, &state, &zipCode, &country, &lat, &lng, &radius); err != nil {
|
|
continue
|
|
}
|
|
seeds = append(seeds, gin.H{
|
|
"id": id,
|
|
"name": name,
|
|
"city": city,
|
|
"state": state,
|
|
"zip_code": zipCode,
|
|
"country": country,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius_meters": radius,
|
|
})
|
|
}
|
|
|
|
// If exact ZIP match returned nothing, try prefix match (e.g. "554" -> multiple ZIPs)
|
|
if len(seeds) == 0 && len(zip) >= 3 {
|
|
rows2, err := h.pool.Query(ctx, `
|
|
SELECT id, name, city, state, COALESCE(zip_code, ''), country, lat, lng, radius_meters
|
|
FROM neighborhood_seeds
|
|
WHERE zip_code LIKE $1
|
|
ORDER BY name
|
|
LIMIT 20
|
|
`, zip+"%")
|
|
if err == nil {
|
|
defer rows2.Close()
|
|
for rows2.Next() {
|
|
var id uuid.UUID
|
|
var name, city, state, zipCode, country string
|
|
var lat, lng float64
|
|
var radius int
|
|
if err := rows2.Scan(&id, &name, &city, &state, &zipCode, &country, &lat, &lng, &radius); err != nil {
|
|
continue
|
|
}
|
|
seeds = append(seeds, gin.H{
|
|
"id": id,
|
|
"name": name,
|
|
"city": city,
|
|
"state": state,
|
|
"zip_code": zipCode,
|
|
"country": country,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius_meters": radius,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if seeds == nil {
|
|
seeds = []gin.H{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"neighborhoods": seeds})
|
|
}
|
|
|
|
// Choose lets a user explicitly pick their home neighborhood.
|
|
// Enforces a 30-day cooldown between changes.
|
|
// POST /neighborhoods/choose { "neighborhood_id": "uuid" }
|
|
func (h *NeighborhoodHandler) Choose(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, err := uuid.Parse(userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
NeighborhoodID string `json:"neighborhood_id" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "neighborhood_id required"})
|
|
return
|
|
}
|
|
|
|
neighborhoodID, err := uuid.Parse(body.NeighborhoodID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid neighborhood_id"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Check 30-day cooldown
|
|
var changedAt *time.Time
|
|
err = h.pool.QueryRow(ctx, `
|
|
SELECT neighborhood_changed_at FROM profiles WHERE id = $1
|
|
`, userID).Scan(&changedAt)
|
|
if err != nil && err != pgx.ErrNoRows {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check cooldown"})
|
|
return
|
|
}
|
|
|
|
if changedAt != nil {
|
|
nextAllowed := changedAt.AddDate(0, 1, 0) // 1 month
|
|
if time.Now().Before(nextAllowed) {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "You can only change your neighborhood once per month",
|
|
"changed_at": changedAt.Format(time.RFC3339),
|
|
"next_allowed_at": nextAllowed.Format(time.RFC3339),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify the neighborhood seed exists
|
|
var seed seedRow
|
|
err = h.pool.QueryRow(ctx, `
|
|
SELECT id, name, city, state, COALESCE(zip_code, ''), country, lat, lng, radius_meters, group_id
|
|
FROM neighborhood_seeds
|
|
WHERE id = $1
|
|
`, neighborhoodID).Scan(
|
|
&seed.ID, &seed.Name, &seed.City, &seed.State, &seed.ZipCode, &seed.Country,
|
|
&seed.Lat, &seed.Lng, &seed.RadiusMeters, &seed.GroupID,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "neighborhood not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up neighborhood"})
|
|
return
|
|
}
|
|
|
|
// Ensure the seed has a group
|
|
if seed.GroupID == nil {
|
|
groupID, err := h.createNeighborhoodGroup(ctx, &seed)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Create group error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create neighborhood group"})
|
|
return
|
|
}
|
|
seed.GroupID = &groupID
|
|
}
|
|
|
|
// Auto-join user to the new neighborhood group
|
|
_, _ = h.autoJoin(ctx, *seed.GroupID, userID)
|
|
|
|
// Update the user's home neighborhood and record the change timestamp
|
|
now := time.Now()
|
|
_, err = h.pool.Exec(ctx, `
|
|
UPDATE profiles
|
|
SET home_neighborhood_id = $1, neighborhood_changed_at = $2, neighborhood_onboarded = TRUE
|
|
WHERE id = $3
|
|
`, neighborhoodID, now, userID)
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] Update profile error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save neighborhood choice"})
|
|
return
|
|
}
|
|
|
|
// Get group details
|
|
var groupName string
|
|
var memberCount int
|
|
h.pool.QueryRow(ctx, `SELECT name, member_count FROM groups WHERE id = $1`, *seed.GroupID).Scan(&groupName, &memberCount)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"neighborhood": gin.H{
|
|
"id": seed.ID,
|
|
"name": seed.Name,
|
|
"city": seed.City,
|
|
"state": seed.State,
|
|
"zip_code": seed.ZipCode,
|
|
"country": seed.Country,
|
|
"lat": seed.Lat,
|
|
"lng": seed.Lng,
|
|
"radius_meters": seed.RadiusMeters,
|
|
},
|
|
"group_id": seed.GroupID,
|
|
"group_name": groupName,
|
|
"member_count": memberCount,
|
|
"changed_at": now.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// GetMyNeighborhood returns the user's chosen home neighborhood and onboarding status.
|
|
// GET /neighborhoods/mine
|
|
func (h *NeighborhoodHandler) GetMyNeighborhood(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, err := uuid.Parse(userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// First check onboarded flag (always available even without a home neighborhood)
|
|
var onboarded bool
|
|
_ = h.pool.QueryRow(ctx, `SELECT COALESCE(neighborhood_onboarded, FALSE) FROM profiles WHERE id = $1`, userID).Scan(&onboarded)
|
|
|
|
var seedID uuid.UUID
|
|
var name, city, state, zipCode, country string
|
|
var lat, lng float64
|
|
var radiusMeters int
|
|
var groupID *uuid.UUID
|
|
var changedAt *time.Time
|
|
|
|
err = h.pool.QueryRow(ctx, `
|
|
SELECT ns.id, ns.name, ns.city, ns.state, COALESCE(ns.zip_code, ''), ns.country,
|
|
ns.lat, ns.lng, ns.radius_meters, ns.group_id,
|
|
p.neighborhood_changed_at
|
|
FROM profiles p
|
|
JOIN neighborhood_seeds ns ON ns.id = p.home_neighborhood_id
|
|
WHERE p.id = $1
|
|
`, userID).Scan(
|
|
&seedID, &name, &city, &state, &zipCode, &country,
|
|
&lat, &lng, &radiusMeters, &groupID, &changedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
// No home neighborhood set — return onboarded status only
|
|
c.JSON(http.StatusOK, gin.H{"onboarded": onboarded})
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("[Neighborhood] GetMyNeighborhood error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch neighborhood"})
|
|
return
|
|
}
|
|
|
|
result := gin.H{
|
|
"onboarded": onboarded,
|
|
"neighborhood": gin.H{
|
|
"id": seedID,
|
|
"name": name,
|
|
"city": city,
|
|
"state": state,
|
|
"zip_code": zipCode,
|
|
"country": country,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius_meters": radiusMeters,
|
|
},
|
|
}
|
|
|
|
if groupID != nil {
|
|
var groupName string
|
|
var memberCount int
|
|
h.pool.QueryRow(ctx, `SELECT name, member_count FROM groups WHERE id = $1`, *groupID).Scan(&groupName, &memberCount)
|
|
result["group_id"] = groupID
|
|
result["group_name"] = groupName
|
|
result["member_count"] = memberCount
|
|
}
|
|
|
|
if changedAt != nil {
|
|
result["changed_at"] = changedAt.Format(time.RFC3339)
|
|
nextAllowed := changedAt.AddDate(0, 1, 0)
|
|
result["next_change_allowed_at"] = nextAllowed.Format(time.RFC3339)
|
|
result["can_change"] = time.Now().After(nextAllowed)
|
|
} else {
|
|
result["can_change"] = true
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// seedRow is an internal struct for scanning neighborhood_seeds rows.
|
|
type seedRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
City string
|
|
State string
|
|
ZipCode string
|
|
Country string
|
|
Lat float64
|
|
Lng float64
|
|
RadiusMeters int
|
|
GroupID *uuid.UUID
|
|
}
|