260 lines
8 KiB
Go
260 lines
8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type BeaconSearchHandler struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewBeaconSearchHandler(pool *pgxpool.Pool) *BeaconSearchHandler {
|
|
return &BeaconSearchHandler{pool: pool}
|
|
}
|
|
|
|
// Search performs a combined search across beacons, board entries, and public groups.
|
|
// Private capsules and private social groups are never returned.
|
|
// GET /api/v1/beacon/search?q=&lat=&long=&radius=&type=all|beacons|board|groups
|
|
func (h *BeaconSearchHandler) Search(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
|
|
query := c.Query("q")
|
|
searchType := c.DefaultQuery("type", "all")
|
|
limitStr := c.DefaultQuery("limit", "20")
|
|
limit, _ := strconv.Atoi(limitStr)
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 20
|
|
}
|
|
|
|
// Optional geo params for proximity sorting
|
|
latStr := c.Query("lat")
|
|
longStr := c.Query("long")
|
|
radiusStr := c.DefaultQuery("radius", "50000")
|
|
var lat, long float64
|
|
var hasGeo bool
|
|
if latStr != "" && longStr != "" {
|
|
lat, _ = strconv.ParseFloat(latStr, 64)
|
|
long, _ = strconv.ParseFloat(longStr, 64)
|
|
hasGeo = true
|
|
}
|
|
radius, _ := strconv.Atoi(radiusStr)
|
|
if radius <= 0 || radius > 100000 {
|
|
radius = 50000
|
|
}
|
|
|
|
result := gin.H{}
|
|
|
|
// ── Beacons (posts with is_beacon = true) ──────────────────────────
|
|
if searchType == "all" || searchType == "beacons" {
|
|
beacons := h.searchBeacons(c, userID, query, lat, long, radius, hasGeo, limit)
|
|
result["beacons"] = beacons
|
|
}
|
|
|
|
// ── Board entries ──────────────────────────────────────────────────
|
|
if searchType == "all" || searchType == "board" {
|
|
entries := h.searchBoard(c, userID, query, lat, long, radius, hasGeo, limit)
|
|
result["board_entries"] = entries
|
|
}
|
|
|
|
// ── Public groups (not encrypted, not private) ─────────────────────
|
|
if searchType == "all" || searchType == "groups" {
|
|
groups := h.searchPublicGroups(c, query, limit)
|
|
result["groups"] = groups
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *BeaconSearchHandler) searchBeacons(c *gin.Context, userID uuid.UUID, query string, lat, long float64, radius int, hasGeo bool, limit int) []gin.H {
|
|
var rows_result []gin.H
|
|
ctx := c.Request.Context()
|
|
|
|
baseQuery := `
|
|
SELECT p.id, LEFT(p.body, 200) as body, p.category,
|
|
p.latitude, p.longitude, p.created_at,
|
|
COALESCE(p.image_url, '') as image_url,
|
|
pr.handle, pr.display_name, COALESCE(pr.avatar_url, '')
|
|
FROM posts p
|
|
JOIN profiles pr ON p.author_id = pr.id
|
|
WHERE p.is_beacon = TRUE AND p.deleted_at IS NULL
|
|
`
|
|
args := []any{}
|
|
argIdx := 1
|
|
|
|
if query != "" {
|
|
baseQuery += ` AND p.body ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
|
|
args = append(args, query)
|
|
argIdx++
|
|
}
|
|
|
|
if hasGeo {
|
|
baseQuery += ` AND ST_DWithin(
|
|
ST_SetSRID(ST_Point(p.longitude, p.latitude), 4326)::geography,
|
|
ST_SetSRID(ST_Point($` + strconv.Itoa(argIdx) + `, $` + strconv.Itoa(argIdx+1) + `), 4326)::geography,
|
|
$` + strconv.Itoa(argIdx+2) + `)`
|
|
args = append(args, long, lat, radius)
|
|
argIdx += 3
|
|
baseQuery += ` ORDER BY ST_Distance(
|
|
ST_SetSRID(ST_Point(p.longitude, p.latitude), 4326)::geography,
|
|
ST_SetSRID(ST_Point($` + strconv.Itoa(argIdx) + `, $` + strconv.Itoa(argIdx+1) + `), 4326)::geography) ASC`
|
|
args = append(args, long, lat)
|
|
argIdx += 2
|
|
} else {
|
|
baseQuery += ` ORDER BY p.created_at DESC`
|
|
}
|
|
|
|
baseQuery += ` LIMIT $` + strconv.Itoa(argIdx)
|
|
args = append(args, limit)
|
|
|
|
rows, err := h.pool.Query(ctx, baseQuery, args...)
|
|
if err != nil {
|
|
return []gin.H{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var body, category, imageURL, handle, displayName, avatarURL string
|
|
var eLat, eLong float64
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&id, &body, &category, &eLat, &eLong, &createdAt,
|
|
&imageURL, &handle, &displayName, &avatarURL); err != nil {
|
|
continue
|
|
}
|
|
rows_result = append(rows_result, gin.H{
|
|
"id": id, "body": body, "category": category,
|
|
"lat": eLat, "long": eLong, "created_at": createdAt,
|
|
"image_url": imageURL, "result_type": "beacon",
|
|
"author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL,
|
|
})
|
|
}
|
|
if rows_result == nil {
|
|
rows_result = []gin.H{}
|
|
}
|
|
return rows_result
|
|
}
|
|
|
|
func (h *BeaconSearchHandler) searchBoard(c *gin.Context, userID uuid.UUID, query string, lat, long float64, radius int, hasGeo bool, limit int) []gin.H {
|
|
var results []gin.H
|
|
ctx := c.Request.Context()
|
|
|
|
baseQuery := `
|
|
SELECT e.id, e.body, COALESCE(e.image_url, ''), e.topic,
|
|
e.lat, e.long, e.upvotes, e.reply_count, e.is_pinned, e.created_at,
|
|
pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''),
|
|
EXISTS(SELECT 1 FROM board_votes bv WHERE bv.user_id = $1 AND bv.entry_id = e.id) AS has_voted
|
|
FROM board_entries e
|
|
JOIN profiles pr ON e.author_id = pr.id
|
|
WHERE e.is_active = TRUE
|
|
`
|
|
args := []any{userID}
|
|
argIdx := 2
|
|
|
|
if query != "" {
|
|
baseQuery += ` AND e.body ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
|
|
args = append(args, query)
|
|
argIdx++
|
|
}
|
|
|
|
if hasGeo {
|
|
baseQuery += ` AND ST_DWithin(e.location, ST_SetSRID(ST_Point($` + strconv.Itoa(argIdx) + `, $` + strconv.Itoa(argIdx+1) + `), 4326)::geography, $` + strconv.Itoa(argIdx+2) + `)`
|
|
args = append(args, long, lat, radius)
|
|
argIdx += 3
|
|
}
|
|
|
|
baseQuery += ` ORDER BY e.is_pinned DESC, e.created_at DESC LIMIT $` + strconv.Itoa(argIdx)
|
|
args = append(args, limit)
|
|
|
|
rows, err := h.pool.Query(ctx, baseQuery, args...)
|
|
if err != nil {
|
|
return []gin.H{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var body, imageURL, topic, handle, displayName, avatarURL string
|
|
var eLat, eLong float64
|
|
var upvotes, replyCount int
|
|
var isPinned, hasVoted bool
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&id, &body, &imageURL, &topic,
|
|
&eLat, &eLong, &upvotes, &replyCount, &isPinned, &createdAt,
|
|
&handle, &displayName, &avatarURL, &hasVoted); err != nil {
|
|
continue
|
|
}
|
|
results = append(results, gin.H{
|
|
"id": id, "body": body, "image_url": imageURL, "topic": topic,
|
|
"lat": eLat, "long": eLong, "upvotes": upvotes, "reply_count": replyCount,
|
|
"is_pinned": isPinned, "created_at": createdAt, "result_type": "board",
|
|
"author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL,
|
|
"has_voted": hasVoted,
|
|
})
|
|
}
|
|
if results == nil {
|
|
results = []gin.H{}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func (h *BeaconSearchHandler) searchPublicGroups(c *gin.Context, query string, limit int) []gin.H {
|
|
var results []gin.H
|
|
ctx := c.Request.Context()
|
|
|
|
// Only return public, non-encrypted groups
|
|
baseQuery := `
|
|
SELECT g.id, g.name, g.description, g.type,
|
|
COALESCE(g.avatar_url, '') as avatar_url,
|
|
g.member_count, g.created_at
|
|
FROM groups g
|
|
WHERE g.is_active = TRUE
|
|
AND g.is_encrypted = FALSE
|
|
AND g.privacy = 'public'
|
|
AND g.type NOT IN ('private_capsule', 'private_social')
|
|
`
|
|
args := []any{}
|
|
argIdx := 1
|
|
|
|
if query != "" {
|
|
baseQuery += ` AND (g.name ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%' OR g.description ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%')`
|
|
args = append(args, query)
|
|
argIdx++
|
|
}
|
|
|
|
baseQuery += ` ORDER BY g.member_count DESC, g.created_at DESC LIMIT $` + strconv.Itoa(argIdx)
|
|
args = append(args, limit)
|
|
|
|
rows, err := h.pool.Query(ctx, baseQuery, args...)
|
|
if err != nil {
|
|
return []gin.H{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var name, description, groupType, avatarURL string
|
|
var memberCount int
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&id, &name, &description, &groupType, &avatarURL, &memberCount, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
results = append(results, gin.H{
|
|
"id": id, "name": name, "description": description,
|
|
"type": groupType, "avatar_url": avatarURL,
|
|
"member_count": memberCount, "created_at": createdAt,
|
|
"result_type": "group",
|
|
})
|
|
}
|
|
if results == nil {
|
|
results = []gin.H{}
|
|
}
|
|
return results
|
|
}
|