package api import ( "net/http" "sort" "time" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) // activityHandler serves the combined RSVP + access-check history for an // event. The WebSocket hub only fans out *live* events to currently- // connected dashboards; this endpoint is the catch-up channel for hosts // who weren't watching when activity happened. type activityHandler struct { events *storage.EventRepo collabs *storage.CollaboratorRepo rsvps *storage.RSVPRepo accessLogs *storage.AccessLogRepo } type activityItem struct { Type string `json:"type"` // "rsvp" | "access_check" Timestamp time.Time `json:"ts"` GuestID string `json:"guest_id"` GuestName string `json:"guest_name"` // RSVP-only Response string `json:"response,omitempty"` PlusOnes int `json:"plus_ones,omitempty"` // Access-check-only Score int `json:"score,omitempty"` Band string `json:"band,omitempty"` Blocked bool `json:"blocked,omitempty"` } // GET /events/{id}/activity?limit=50 // // Returns the most recent N activity items (RSVPs + scored access checks) // for an event, sorted newest first. Frontends use this on dashboard mount // to backfill the live monitor with history. func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok { return } limit := atoiOr(r.URL.Query().Get("limit"), 50) if limit <= 0 || limit > 200 { limit = 50 } // Pull from each source. We grab `limit` from each so that after // merging we still have at least `limit` of the truly newest items. rsvps, err := h.rsvps.ListRecentByEvent(r.Context(), eventID, limit) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load activity") return } checks, err := h.accessLogs.ListRecentScoredByEvent(r.Context(), eventID, limit) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load activity") return } items := make([]activityItem, 0, len(rsvps)+len(checks)) for _, a := range rsvps { items = append(items, activityItem{ Type: "rsvp", Timestamp: a.SubmittedAt, GuestID: a.GuestID.String(), GuestName: a.GuestName, Response: a.Response, PlusOnes: a.PlusOnes, }) } for _, c := range checks { items = append(items, activityItem{ Type: "access_check", Timestamp: c.CreatedAt, GuestID: c.GuestID.String(), GuestName: c.GuestName, Score: c.Score, Band: bandFromScore(c.Score), Blocked: c.Score >= 80, }) } sort.Slice(items, func(i, j int) bool { return items[i].Timestamp.After(items[j].Timestamp) }) if len(items) > limit { items = items[:limit] } writeJSON(w, http.StatusOK, map[string]any{ "activity": items, }) } // bandFromScore mirrors the friendly buckets used by the live WebSocket // pipeline so backfilled items and live items render the same way in the // dashboard feed. Thresholds match the fraud engine's intent: 0–29 looks // normal, 30–59 worth a glance, 60–79 suspicious, ≥80 blocked. func bandFromScore(score int) string { switch { case score >= 80: return "block" case score >= 60: return "high" case score >= 30: return "medium" default: return "low" } }