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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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: 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user