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 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)`) pattern := "%" + strings.ToLower(q) + "%" args = append(args, 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"` } results := []userResult{} for rows.Next() { var u userResult if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil { continue } results = append(results, u) } c.JSON(http.StatusOK, gin.H{"users": results}) }