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:
Kwaku Danso
2026-05-11 21:08:56 +01:00
parent f760fc3e21
commit 3f8bc58ca9
89 changed files with 22729 additions and 0 deletions
+126
View File
@@ -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: 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"
}
}