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"
}
}
+232
View File
@@ -0,0 +1,232 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type eventHandler struct {
repo *storage.EventRepo
}
type createEventRequest struct {
HostID string `json:"host_id"`
Name string `json:"name"`
Slug string `json:"slug"`
EventDate time.Time `json:"event_date"`
Venue string `json:"venue"`
MaxCapacity int `json:"max_capacity"`
Settings map[string]any `json:"settings"`
Status string `json:"status"`
}
var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
var req createEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if !slugRe.MatchString(req.Slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens")
return
}
if req.EventDate.IsZero() {
writeError(w, http.StatusBadRequest, "event_date is required")
return
}
hostID, err := uuid.Parse(req.HostID)
if err != nil {
writeError(w, http.StatusBadRequest, "host_id must be a valid uuid")
return
}
status := domain.EventStatus(req.Status)
if status == "" {
status = domain.EventStatusDraft
}
if !status.Valid() {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
ev, err := h.repo.Create(r.Context(), storage.CreateEventParams{
HostID: hostID,
Name: req.Name,
Slug: req.Slug,
EventDate: req.EventDate,
Venue: req.Venue,
MaxCapacity: req.MaxCapacity,
Settings: req.Settings,
Status: status,
})
if err != nil {
if errors.Is(err, domain.ErrSlugTaken) {
writeError(w, http.StatusConflict, "slug already in use")
return
}
writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
writeJSON(w, http.StatusCreated, ev)
}
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
ev, err := h.repo.Get(r.Context(), id)
if 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
}
writeJSON(w, http.StatusOK, ev)
}
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := atoiOr(q.Get("limit"), 50)
offset := atoiOr(q.Get("offset"), 0)
var hostID uuid.UUID
if v := q.Get("host_id"); v != "" {
parsed, err := uuid.Parse(v)
if err != nil {
writeError(w, http.StatusBadRequest, "host_id must be a valid uuid")
return
}
hostID = parsed
}
events, err := h.repo.List(r.Context(), hostID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*domain.Event{}
}
writeJSON(w, http.StatusOK, map[string]any{
"events": events,
"limit": limit,
"offset": offset,
})
}
type updateEventRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
EventDate *time.Time `json:"event_date"`
Venue *string `json:"venue"`
MaxCapacity *int `json:"max_capacity"`
Settings *map[string]any `json:"settings"`
Status *string `json:"status"`
}
func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
var req updateEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
params := storage.UpdateEventParams{
Name: req.Name,
EventDate: req.EventDate,
Venue: req.Venue,
MaxCapacity: req.MaxCapacity,
Settings: req.Settings,
}
if req.Slug != nil {
if !slugRe.MatchString(*req.Slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens")
return
}
params.Slug = req.Slug
}
if req.Status != nil {
s := domain.EventStatus(*req.Status)
if !s.Valid() {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
params.Status = &s
}
ev, err := h.repo.Update(r.Context(), id, params)
if err != nil {
switch {
case errors.Is(err, domain.ErrEventNotFound):
writeError(w, http.StatusNotFound, "event not found")
case errors.Is(err, domain.ErrSlugTaken):
writeError(w, http.StatusConflict, "slug already in use")
default:
writeError(w, http.StatusInternalServerError, "failed to update event")
}
return
}
writeJSON(w, http.StatusOK, ev)
}
func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseIDParam(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) {
raw := r.PathValue(name)
id, err := uuid.Parse(raw)
if err != nil {
writeError(w, http.StatusBadRequest, name+" must be a valid uuid")
return uuid.Nil, false
}
return id, true
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
if n, err := strconv.Atoi(s); err == nil {
return n
}
return fallback
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type guestHandler struct {
guests *storage.GuestRepo
events *storage.EventRepo
}
type createGuestRequest struct {
Name string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"`
TableNumber *int `json:"table_number"`
}
func (h *guestHandler) create(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
}
var req createGuestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return
}
g, err := h.guests.Create(r.Context(), storage.CreateGuestParams{
EventID: eventID,
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
TableNumber: req.TableNumber,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create guest")
return
}
writeJSON(w, http.StatusCreated, g)
}
func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
q := r.URL.Query()
limit := atoiOr(q.Get("limit"), 100)
offset := atoiOr(q.Get("offset"), 0)
guests, err := h.guests.ListByEventWithRSVP(r.Context(), eventID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list guests")
return
}
if guests == nil {
guests = []*storage.GuestWithRSVP{}
}
stats := struct {
Total int `json:"total"`
Attending int `json:"attending"`
Declined int `json:"declined"`
Maybe int `json:"maybe"`
Pending int `json:"pending"`
}{Total: len(guests)}
for _, g := range guests {
switch {
case g.RSVPResponse == nil:
stats.Pending++
case *g.RSVPResponse == string(domain.RSVPAttending):
stats.Attending++
case *g.RSVPResponse == string(domain.RSVPDeclined):
stats.Declined++
case *g.RSVPResponse == string(domain.RSVPMaybe):
stats.Maybe++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"guests": guests,
"stats": stats,
"limit": limit,
"offset": offset,
})
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type healthHandler struct {
pool *pgxpool.Pool
}
func (h *healthHandler) live(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *healthHandler) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.pool.Ping(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unavailable",
"db": "down",
})
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"db": "up",
})
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"bufio"
"errors"
"log/slog"
"net"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
// Hijack passes through to the underlying ResponseWriter so WebSocket
// upgrades work despite the middleware wrapper. Returning ErrNotSupported
// (rather than a custom error) lets callers detect this generically.
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := s.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("response writer does not support hijack")
}
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
}
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic", "err", rec, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
)
type errorBody struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if body == nil {
return
}
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Error("write json", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/fraud"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type rsvpPublisher interface {
PublishRSVPConfirmed(ctx context.Context, evt natspub.RSVPConfirmed) error
}
type fraudScorer interface {
Score(ctx context.Context, in fraud.ScoreInput) fraud.Decision
}
type rsvpHandler struct {
logger *slog.Logger
guests *storage.GuestRepo
tokens *storage.TokenRepo
events *storage.EventRepo
rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo
scorer fraudScorer
pub rsvpPublisher
}
type submitRSVPRequest struct {
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"`
Fingerprint map[string]any `json:"fingerprint"`
}
type submitRSVPResponse struct {
RSVP *domain.RSVP `json:"rsvp"`
Decision fraud.Decision `json:"fraud"`
Blocked bool `json:"blocked"`
}
// POST /rsvp/{token} — synchronous fraud check + RSVP recording.
func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
var req submitRSVPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
resp := domain.RSVPResponse(req.Response)
if !resp.Valid() {
writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
return
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
fingerprint := mergeFingerprint(req.Fingerprint, collectFingerprint(r))
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
decision := h.scorer.Score(r.Context(), fraud.ScoreInput{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: stringifyFingerprint(fingerprint),
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
})
if fraud.IsBlock(decision) {
writeJSON(w, http.StatusForbidden, submitRSVPResponse{
Decision: decision,
Blocked: true,
})
return
}
score := decision.Score
rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
GuestID: guest.ID,
Response: resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
writeError(w, http.StatusConflict, "rsvp already submitted for this guest")
return
}
h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to record rsvp")
return
}
if err := h.tokens.MarkUsed(r.Context(), tk.ID); err != nil {
h.logger.Warn("mark token used", "err", err, "token_id", tk.ID)
}
go func(evt natspub.RSVPConfirmed) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishRSVPConfirmed(ctx, evt); err != nil {
h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID)
}
}(natspub.RSVPConfirmed{
EventID: event.ID,
GuestID: guest.ID,
RSVPID: rsvp.ID,
Response: string(rsvp.Response),
PlusOnes: rsvp.PlusOnes,
RiskScore: &score,
SubmittedAt: rsvp.SubmittedAt,
})
writeJSON(w, http.StatusCreated, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
})
}
func mergeFingerprint(client map[string]any, server map[string]any) map[string]any {
out := make(map[string]any, len(server)+len(client))
for k, v := range server {
out[k] = v
}
for k, v := range client {
out["client_"+k] = v
}
return out
}
func stringifyFingerprint(fp map[string]any) map[string]string {
if fp == nil {
return nil
}
out := make(map[string]string, len(fp))
for k, v := range fp {
switch tv := v.(type) {
case string:
out[k] = tv
default:
b, _ := json.Marshal(tv)
out[k] = string(b)
}
}
return out
}
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/storage"
)
type Server struct {
logger *slog.Logger
db *storage.DB
hub *Hub
users *userHandler
events *eventHandler
guests *guestHandler
tokens *tokenHandler
rsvps *rsvpHandler
activity *activityHandler
ws *wsHandler
health *healthHandler
}
type ServerDeps struct {
Logger *slog.Logger
DB *storage.DB
Hub *Hub
AccessPublisher accessPublisher
RSVPPublisher rsvpPublisher
FraudScorer fraudScorer
TokenTTL time.Duration
}
func NewServer(deps ServerDeps) *Server {
eventRepo := storage.NewEventRepo(deps.DB)
guestRepo := storage.NewGuestRepo(deps.DB)
tokenRepo := storage.NewTokenRepo(deps.DB)
rsvpRepo := storage.NewRSVPRepo(deps.DB)
accessRepo := storage.NewAccessLogRepo(deps.DB)
userRepo := storage.NewUserRepo(deps.DB)
hub := deps.Hub
if hub == nil {
hub = NewHub(deps.Logger)
}
return &Server{
logger: deps.Logger,
db: deps.DB,
hub: hub,
users: &userHandler{repo: userRepo},
events: &eventHandler{repo: eventRepo},
guests: &guestHandler{guests: guestRepo, events: eventRepo},
tokens: &tokenHandler{
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
accessLogs: accessRepo,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
},
rsvps: &rsvpHandler{
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
rsvps: rsvpRepo,
accessLogs: accessRepo,
scorer: deps.FraudScorer,
pub: deps.RSVPPublisher,
},
activity: &activityHandler{
events: eventRepo,
rsvps: rsvpRepo,
accessLogs: accessRepo,
},
ws: &wsHandler{logger: deps.Logger, hub: hub},
health: &healthHandler{pool: deps.DB.Pool},
}
}
func (s *Server) Hub() *Hub { return s.hub }
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.health.live)
mux.HandleFunc("GET /health/ready", s.health.ready)
mux.HandleFunc("POST /users", s.users.upsert)
mux.HandleFunc("POST /events", s.events.create)
mux.HandleFunc("GET /events", s.events.list)
mux.HandleFunc("GET /events/{id}", s.events.get)
mux.HandleFunc("PATCH /events/{id}", s.events.update)
mux.HandleFunc("DELETE /events/{id}", s.events.delete)
mux.HandleFunc("POST /events/{id}/guests", s.guests.create)
mux.HandleFunc("GET /events/{id}/guests", s.guests.list)
mux.HandleFunc("GET /events/{id}/activity", s.activity.list)
mux.HandleFunc("POST /events/{id}/guests/{guest_id}/tokens", s.tokens.issue)
mux.HandleFunc("GET /access/{token}", s.tokens.access)
mux.HandleFunc("POST /rsvp/{token}", s.rsvps.submit)
mux.HandleFunc("GET /ws/events/{id}", s.ws.handle)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
})
var h http.Handler = mux
h = corsMiddleware(h)
h = loggingMiddleware(s.logger)(h)
h = recoverMiddleware(s.logger)(h)
return h
}
// Permissive CORS for the dev frontend on a different origin. In production
// the frontend is served from the same domain so this is largely a no-op.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Device-Fingerprint")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+197
View File
@@ -0,0 +1,197 @@
package api
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type accessPublisher interface {
PublishAccessAttempted(ctx context.Context, evt natspub.AccessAttempted) error
}
type tokenHandler struct {
logger *slog.Logger
guests *storage.GuestRepo
tokens *storage.TokenRepo
events *storage.EventRepo
accessLogs *storage.AccessLogRepo
gen *auth.Generator
ttl time.Duration
pub accessPublisher
}
type issueTokenResponse struct {
Token string `json:"token"`
TokenID uuid.UUID `json:"token_id"`
Meta *domain.Token `json:"meta"`
}
// POST /events/{id}/guests/{guest_id}/tokens — issue a token for the guest.
func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
guestID, ok := parseIDParam(w, r, "guest_id")
if !ok {
return
}
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrGuestNotFound) {
writeError(w, http.StatusNotFound, "guest not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if guest.EventID != eventID {
writeError(w, http.StatusNotFound, "guest not found in event")
return
}
raw, hash, err := h.gen.Generate()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{
GuestID: guestID,
TokenHash: hash,
ExpiresAt: time.Now().UTC().Add(h.ttl),
})
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusCreated, issueTokenResponse{
Token: raw,
TokenID: tk.ID,
Meta: tk,
})
}
type accessResponse struct {
Guest *domain.Guest `json:"guest"`
Event *domain.Event `json:"event"`
Token *domain.Token `json:"token"`
AccessLog uuid.UUID `json:"access_log_id"`
}
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
fingerprint := collectFingerprint(r)
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
go func(evt natspub.AccessAttempted) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil {
h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID)
}
}(natspub.AccessAttempted{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: fingerprint,
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
OccurredAt: time.Now().UTC(),
})
writeJSON(w, http.StatusOK, accessResponse{
Guest: guest,
Event: event,
Token: tk,
AccessLog: accessLogID,
})
}
func collectFingerprint(r *http.Request) map[string]any {
fp := map[string]any{
"user_agent": r.UserAgent(),
"accept_language": r.Header.Get("Accept-Language"),
"accept_encoding": r.Header.Get("Accept-Encoding"),
}
if v := r.Header.Get("Sec-CH-UA-Platform"); v != "" {
fp["platform"] = v
}
if v := r.Header.Get("X-Device-Fingerprint"); v != "" {
fp["client_fingerprint"] = v
}
return fp
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i > 0 {
host = host[:i]
}
return host
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/mail"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type userHandler struct {
repo *storage.UserRepo
}
type upsertUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
// POST /users — idempotent: returns the existing user if the email already
// exists, creates one otherwise. This keeps the demo flow simple without
// requiring real auth.
func (h *userHandler) upsert(w http.ResponseWriter, r *http.Request) {
var req upsertUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, err := mail.ParseAddress(req.Email); err != nil {
writeError(w, http.StatusBadRequest, "email is invalid")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
u, err := h.repo.Create(r.Context(), req.Email, req.Name)
if err == nil {
writeJSON(w, http.StatusCreated, u)
return
}
if errors.Is(err, domain.ErrEmailTaken) {
existing, getErr := h.repo.GetByEmail(r.Context(), req.Email)
if getErr != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
writeJSON(w, http.StatusOK, existing)
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
}
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/google/uuid"
)
// WSEvent is the envelope pushed over WebSocket to dashboard clients.
type WSEvent struct {
Type string `json:"type"`
EventID uuid.UUID `json:"event_id"`
Payload json.RawMessage `json:"payload"`
Timestamp time.Time `json:"timestamp"`
}
type subscriber struct {
conn *websocket.Conn
send chan []byte
closed chan struct{}
}
// Hub fans out per-event WebSocket events to subscribers. Connections are
// keyed by event_id; a single dashboard page subscribes to one event at a
// time. Backpressure: if a slow client falls behind, we drop the message
// for that subscriber rather than block the broadcaster.
type Hub struct {
logger *slog.Logger
mu sync.RWMutex
subs map[uuid.UUID]map[*subscriber]struct{}
}
func NewHub(logger *slog.Logger) *Hub {
return &Hub{
logger: logger,
subs: make(map[uuid.UUID]map[*subscriber]struct{}),
}
}
// Broadcast publishes evt to all subscribers of evt.EventID.
func (h *Hub) Broadcast(evt WSEvent) {
if evt.Timestamp.IsZero() {
evt.Timestamp = time.Now().UTC()
}
body, err := json.Marshal(evt)
if err != nil {
h.logger.Error("ws marshal", "err", err)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for s := range h.subs[evt.EventID] {
select {
case s.send <- body:
default:
// drop on slow client; the connection will be closed when its
// reader goroutine notices the closed channel.
h.logger.Warn("ws subscriber slow, dropping message", "event_id", evt.EventID)
}
}
}
func (h *Hub) add(eventID uuid.UUID, s *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if h.subs[eventID] == nil {
h.subs[eventID] = make(map[*subscriber]struct{})
}
h.subs[eventID][s] = struct{}{}
}
func (h *Hub) remove(eventID uuid.UUID, s *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if subs, ok := h.subs[eventID]; ok {
delete(subs, s)
if len(subs) == 0 {
delete(h.subs, eventID)
}
}
}
type wsHandler struct {
logger *slog.Logger
hub *Hub
}
// GET /ws/events/{id} — dashboard live feed for one event.
func (h *wsHandler) handle(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// In dev the frontend runs on a different origin (localhost:3000 → localhost:8080).
// We're not relying on cookies, so it's safe to skip the same-origin check.
InsecureSkipVerify: true,
})
if err != nil {
h.logger.Warn("ws accept", "err", err)
return
}
sub := &subscriber{
conn: conn,
send: make(chan []byte, 32),
closed: make(chan struct{}),
}
h.hub.add(eventID, sub)
defer h.hub.remove(eventID, sub)
ctx := conn.CloseRead(r.Context())
pingTicker := time.NewTicker(20 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
conn.Close(websocket.StatusNormalClosure, "")
return
case msg := <-sub.send:
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
if err != nil {
conn.Close(websocket.StatusInternalError, "write failed")
return
}
case <-pingTicker.C:
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
return
}
}
}
}