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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user