Files
guestguard/internal/api/activity.go
T
Kwaku Danso 3f8bc58ca9 feat: build core API, fraud engine, notifier, and frontend
Phase 1 — Core API (Go):
- Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5
- HMAC-signed per-guest tokens with format validation
- Health endpoint with DB ping, slog JSON logging, graceful shutdown

Phase 2 — NATS + Fraud Engine:
- NATS JetStream pub/sub with explicit-ack consumers
- Python/FastAPI fraud engine with heuristic risk scoring
  (fingerprint mismatch, IP change, missing signals, repeated access)
- gRPC sync scoring with 250ms fail-open timeout
- Per-guest baseline tracking; risk bands low/medium/high/block

Phase 3 — Notifications + Frontend:
- Notification worker scaffolding (Twilio/SES stubs, retry/backoff)
- Nuxt 3 frontend with Tailwind dark theme + brand green
- Live monitor via WebSocket with auto-reconnect
- Activity history endpoint backfills monitor with RSVPs +
  scored access checks (including blocked attempts)

UX polish:
- Marketing-friendly landing page (hero mockup, how-it-works,
  features, use cases, testimonials, FAQ, final CTA)
- Animated layered card mockups on landing + new-event page
- Plus-ones stepper, RSVP status badges, filter buttons
- Friendly access-check labels (Verified/Review/Suspicious/Blocked)
- Dashboard hydration fix via ClientOnly wrapper

Infrastructure:
- docker-compose for full local dev (postgres, nats, api,
  fraud-engine, notifier, frontend)
- Multi-stage Dockerfiles, non-root UID 1000
- Integration tests with testcontainers-go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:08:56 +01:00

127 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"errors"
"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
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) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.events.Get(r.Context(), eventID); err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load event")
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: 029 looks
// normal, 3059 worth a glance, 6079 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"
}
}