393 lines
11 KiB
Go
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)
|
|
}
|