sojorn/go-backend/internal/handlers/admin_neighborhoods_handler.go
2026-02-15 00:33:24 -06:00

393 lines
11 KiB
Go

package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ListNeighborhoods returns neighborhood seeds with group and activity metadata.
func (h *AdminHandler) ListNeighborhoods(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
search := strings.TrimSpace(c.Query("search"))
zip := strings.TrimSpace(c.Query("zip"))
sortBy := strings.TrimSpace(c.DefaultQuery("sort", "name"))
order := strings.ToLower(strings.TrimSpace(c.DefaultQuery("order", "asc")))
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
if order != "desc" {
order = "asc"
}
sortColumn := "ns.name"
switch sortBy {
case "zip":
sortColumn = "ns.zip_code"
case "members":
sortColumn = "g.member_count"
case "created":
sortColumn = "ns.created_at"
}
base := `
FROM neighborhood_seeds ns
LEFT JOIN groups g ON g.id = ns.group_id
WHERE 1=1
`
args := []any{}
argIdx := 1
if search != "" {
base += fmt.Sprintf(" AND (ns.name ILIKE $%d OR ns.city ILIKE $%d OR ns.state ILIKE $%d OR ns.zip_code ILIKE $%d OR g.name ILIKE $%d)", argIdx, argIdx, argIdx, argIdx, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
if zip != "" {
base += fmt.Sprintf(" AND ns.zip_code ILIKE $%d", argIdx)
args = append(args, "%"+zip+"%")
argIdx++
}
var total int
if err := h.pool.QueryRow(ctx, "SELECT COUNT(*) "+base, args...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count neighborhoods"})
return
}
query := `
SELECT
ns.id,
ns.name,
ns.city,
ns.state,
COALESCE(ns.zip_code, ''),
ns.country,
ns.lat,
ns.lng,
ns.radius_meters,
ns.created_at,
ns.group_id,
COALESCE(g.name, ''),
COALESCE(g.member_count, 0),
COALESCE((
SELECT COUNT(*) FROM group_members gm
WHERE gm.group_id = ns.group_id AND gm.role IN ('owner','admin')
), 0) AS admin_count,
COALESCE((
SELECT COUNT(*) FROM board_entries be
WHERE be.is_active = TRUE
AND ST_DWithin(be.location, ST_SetSRID(ST_MakePoint(ns.lng, ns.lat), 4326)::geography, ns.radius_meters)
), 0) AS board_post_count,
COALESCE((
SELECT COUNT(*) FROM group_posts gp
WHERE gp.group_id = ns.group_id AND gp.is_deleted = FALSE
), 0) AS group_post_count
` + base + fmt.Sprintf(" ORDER BY %s %s, ns.created_at DESC LIMIT $%d OFFSET $%d", sortColumn, order, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list neighborhoods"})
return
}
defer rows.Close()
items := make([]gin.H, 0)
for rows.Next() {
var id uuid.UUID
var name, city, state, zipCode, country string
var lat, lng float64
var radiusMeters, memberCount, adminCount, boardCount, groupPostCount int
var createdAt time.Time
var groupID *uuid.UUID
var groupName string
if err := rows.Scan(
&id, &name, &city, &state, &zipCode, &country,
&lat, &lng, &radiusMeters, &createdAt,
&groupID, &groupName, &memberCount, &adminCount, &boardCount, &groupPostCount,
); err != nil {
continue
}
items = append(items, gin.H{
"id": id,
"name": name,
"city": city,
"state": state,
"zip_code": zipCode,
"country": country,
"lat": lat,
"lng": lng,
"radius_meters": radiusMeters,
"created_at": createdAt,
"group_id": groupID,
"group_name": groupName,
"member_count": memberCount,
"admin_count": adminCount,
"board_post_count": boardCount,
"group_post_count": groupPostCount,
})
}
c.JSON(http.StatusOK, gin.H{
"neighborhoods": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// SetNeighborhoodAdmin assigns or removes neighborhood admins by role on group_members.
func (h *AdminHandler) SetNeighborhoodAdmin(c *gin.Context) {
ctx := c.Request.Context()
seedID := c.Param("id")
var req struct {
UserID string `json:"user_id" binding:"required"`
Action string `json:"action" binding:"required,oneof=assign remove"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var groupID uuid.UUID
if err := h.pool.QueryRow(ctx, `SELECT group_id FROM neighborhood_seeds WHERE id = $1::uuid`, seedID).Scan(&groupID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "neighborhood or group not found"})
return
}
if req.Action == "assign" {
_, err := h.pool.Exec(ctx, `
INSERT INTO group_members (group_id, user_id, role)
VALUES ($1, $2::uuid, 'admin')
ON CONFLICT (group_id, user_id) DO UPDATE SET role = 'admin'
`, groupID, req.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign admin"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "admin assigned"})
return
}
_, err := h.pool.Exec(ctx, `
UPDATE group_members
SET role = 'member'
WHERE group_id = $1 AND user_id = $2::uuid AND role = 'admin'
`, groupID, req.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove admin"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "admin removed"})
}
// ListNeighborhoodAdmins returns current owner/admin members for a neighborhood group.
func (h *AdminHandler) ListNeighborhoodAdmins(c *gin.Context) {
ctx := c.Request.Context()
seedID := c.Param("id")
var groupID uuid.UUID
if err := h.pool.QueryRow(ctx, `SELECT group_id FROM neighborhood_seeds WHERE id = $1::uuid`, seedID).Scan(&groupID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "neighborhood or group not found"})
return
}
rows, err := h.pool.Query(ctx, `
SELECT gm.user_id, gm.role, gm.joined_at,
COALESCE(p.handle, ''), COALESCE(p.display_name, ''), COALESCE(p.avatar_url, '')
FROM group_members gm
JOIN profiles p ON p.id = gm.user_id
WHERE gm.group_id = $1 AND gm.role IN ('owner', 'admin')
ORDER BY CASE gm.role WHEN 'owner' THEN 0 ELSE 1 END, gm.joined_at ASC
`, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list neighborhood admins"})
return
}
defer rows.Close()
admins := make([]gin.H, 0)
for rows.Next() {
var userID uuid.UUID
var role, handle, displayName, avatarURL string
var joinedAt time.Time
if err := rows.Scan(&userID, &role, &joinedAt, &handle, &displayName, &avatarURL); err != nil {
continue
}
admins = append(admins, gin.H{
"user_id": userID,
"role": role,
"joined_at": joinedAt,
"handle": handle,
"display_name": displayName,
"avatar_url": avatarURL,
})
}
c.JSON(http.StatusOK, gin.H{"admins": admins})
}
// ListNeighborhoodBoardEntries lists board content inside a neighborhood radius for moderation.
func (h *AdminHandler) ListNeighborhoodBoardEntries(c *gin.Context) {
ctx := c.Request.Context()
seedID := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
search := strings.TrimSpace(c.Query("search"))
active := strings.TrimSpace(c.Query("active"))
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var lat, lng float64
var radius int
if err := h.pool.QueryRow(ctx, `SELECT lat, lng, radius_meters FROM neighborhood_seeds WHERE id = $1::uuid`, seedID).Scan(&lat, &lng, &radius); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "neighborhood not found"})
return
}
base := `
FROM board_entries be
JOIN profiles p ON p.id = be.author_id
WHERE ST_DWithin(be.location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
`
args := []any{lng, lat, radius}
argIdx := 4
if search != "" {
base += fmt.Sprintf(" AND be.body ILIKE $%d", argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
if active == "true" || active == "false" {
base += fmt.Sprintf(" AND be.is_active = $%d", argIdx)
args = append(args, active == "true")
argIdx++
}
var total int
if err := h.pool.QueryRow(ctx, "SELECT COUNT(*) "+base, args...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count board entries"})
return
}
query := `
SELECT be.id, be.body, be.topic, be.is_active, be.is_pinned, be.upvotes, be.reply_count, be.created_at,
p.id, COALESCE(p.handle, ''), COALESCE(p.display_name, '')
` + base + fmt.Sprintf(" ORDER BY be.is_pinned DESC, be.created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list board entries"})
return
}
defer rows.Close()
entries := make([]gin.H, 0)
for rows.Next() {
var id, authorID uuid.UUID
var body, topic, handle, displayName string
var isActive, isPinned bool
var upvotes, replyCount int
var createdAt time.Time
if err := rows.Scan(&id, &body, &topic, &isActive, &isPinned, &upvotes, &replyCount, &createdAt, &authorID, &handle, &displayName); err != nil {
continue
}
entries = append(entries, gin.H{
"id": id,
"body": body,
"topic": topic,
"is_active": isActive,
"is_pinned": isPinned,
"upvotes": upvotes,
"reply_count": replyCount,
"created_at": createdAt,
"author": gin.H{
"id": authorID,
"handle": handle,
"display_name": displayName,
},
})
}
c.JSON(http.StatusOK, gin.H{
"entries": entries,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *AdminHandler) UpdateNeighborhoodBoardEntry(c *gin.Context) {
ctx := c.Request.Context()
entryID := c.Param("entryId")
var req struct {
IsActive *bool `json:"is_active"`
IsPinned *bool `json:"is_pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.IsActive == nil && req.IsPinned == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "is_active or is_pinned is required"})
return
}
sets := make([]string, 0, 2)
args := make([]any, 0, 3)
argIdx := 1
if req.IsActive != nil {
sets = append(sets, fmt.Sprintf("is_active = $%d", argIdx))
args = append(args, *req.IsActive)
argIdx++
}
if req.IsPinned != nil {
sets = append(sets, fmt.Sprintf("is_pinned = $%d", argIdx))
args = append(args, *req.IsPinned)
argIdx++
}
sets = append(sets, "updated_at = NOW()")
args = append(args, entryID)
query := fmt.Sprintf("UPDATE board_entries SET %s WHERE id = $%d::uuid", strings.Join(sets, ", "), argIdx)
_, err := h.pool.Exec(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update board entry"})
return
}
resp := gin.H{"message": "board entry updated"}
if req.IsActive != nil {
resp["is_active"] = *req.IsActive
}
if req.IsPinned != nil {
resp["is_pinned"] = *req.IsPinned
}
c.JSON(http.StatusOK, resp)
}