package handlers // presence.go — Heartbeat upsert and status query // // 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" "armature/store" ) const presenceOnlineThreshold = 90 * time.Second type PresenceHandler struct { stores store.Stores } func NewPresenceHandler(s store.Stores) *PresenceHandler { return &PresenceHandler{stores: s} } // PresenceHeartbeat upserts the calling user's last_seen timestamp. func (h *PresenceHandler) Heartbeat(c *gin.Context) { userID := getUserID(c) if err := h.stores.Presence.Heartbeat(c.Request.Context(), userID); 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 (h *PresenceHandler) Query(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] } // Trim whitespace cleaned := make([]string, 0, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) if id != "" { cleaned = append(cleaned, id) } } threshold := time.Now().Add(-presenceOnlineThreshold) result, err := h.stores.Presence.GetStatuses(c.Request.Context(), cleaned, threshold) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "presence query failed"}) return } 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 func (h *PresenceHandler) SearchUsers(c *gin.Context) { userID := getUserID(c) q := strings.TrimSpace(c.Query("q")) results, err := h.stores.Users.SearchActive(c.Request.Context(), userID, q) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } c.JSON(http.StatusOK, gin.H{"data": results}) } // ResolveUsers returns identity records for a batch of user IDs. // GET /api/v1/users/resolve?ids=uuid1,uuid2,... func (h *PresenceHandler) ResolveUsers(c *gin.Context) { raw := strings.TrimSpace(c.Query("ids")) if raw == "" { c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}}) return } ids := make([]string, 0) for _, id := range strings.Split(raw, ",") { id = strings.TrimSpace(id) if id != "" { ids = append(ids, id) } } if len(ids) == 0 { c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}}) return } results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"}) return } // Build map keyed by user ID for O(1) client-side lookups m := make(map[string]interface{}, len(results)) for _, u := range results { m[u.ID] = u } c.JSON(http.StatusOK, gin.H{"data": m}) }