This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/presence.go
2026-03-07 11:27:24 +00:00

118 lines
3.2 KiB
Go

package handlers
// presence.go — Heartbeat upsert and status query (v0.23.1)
//
// Clients POST /api/v1/presence/heartbeat every 30s while active.
// GET /api/v1/presence?users=id1,id2 returns current status.
// Online = last_seen within 90s.
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const presenceOnlineThreshold = 90 * time.Second
// PresenceHeartbeat upserts the calling user's last_seen timestamp.
func PresenceHeartbeat(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO user_presence (user_id, last_seen, status)
VALUES ($1, NOW(), 'online')
ON CONFLICT (user_id) DO UPDATE
SET last_seen = NOW(), status = 'online'
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// PresenceQuery returns online/offline status for a list of user IDs.
// Query param: ?users=uuid1,uuid2,...
func PresenceQuery(c *gin.Context) {
raw := c.Query("users")
if raw == "" {
c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}})
return
}
ids := strings.Split(raw, ",")
if len(ids) > 100 {
ids = ids[:100]
}
threshold := time.Now().Add(-presenceOnlineThreshold)
result := make(map[string]string, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
var lastSeen time.Time
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT last_seen FROM user_presence WHERE user_id = $1
`), id).Scan(&lastSeen)
if err != nil || lastSeen.Before(threshold) {
result[id] = "offline"
} else {
result[id] = "online"
}
}
c.JSON(http.StatusOK, gin.H{"presence": result})
}
// SearchUsers returns a lightweight list of approved users matching a query.
// GET /api/v1/users/search?q=alice — matches against username and display_name.
// Returns at most 20 results. Excludes the calling user.
func SearchUsers(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
query := database.Q(`
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
FROM users
WHERE is_active = true AND id != $1
`)
args := []interface{}{userID}
if q != "" {
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`)
pattern := "%" + strings.ToLower(q) + "%"
args = append(args, pattern, pattern, pattern)
}
query += ` ORDER BY username LIMIT 20`
rows, err := database.DB.QueryContext(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
defer rows.Close()
type userResult struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
}
results := []userResult{}
for rows.Next() {
var u userResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
continue
}
results = append(results, u)
}
c.JSON(http.StatusOK, gin.H{"users": results})
}