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" "github.com/patbritton/sojorn-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 }