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,10 @@
|
||||
.git
|
||||
.github
|
||||
.claude
|
||||
docs
|
||||
frontend
|
||||
fraud-engine
|
||||
**/*.md
|
||||
**/Dockerfile
|
||||
docker-compose.yml
|
||||
Makefile
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
*.test
|
||||
*.out
|
||||
coverage.*
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
node_modules/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
fraud-engine/.venv/
|
||||
@@ -0,0 +1,55 @@
|
||||
.PHONY: build run test integration-test vet tidy fmt up down logs clean fraud-test fraud-install proto
|
||||
|
||||
build:
|
||||
go build -o bin/api ./cmd/api
|
||||
|
||||
run:
|
||||
go run ./cmd/api
|
||||
|
||||
test:
|
||||
go test ./... -race -count=1
|
||||
|
||||
# Integration tests spin up real Postgres + NATS via testcontainers and an
|
||||
# in-process gRPC stub for the fraud engine. Requires Docker.
|
||||
integration-test:
|
||||
go test -tags=integration -count=1 -timeout=5m ./test/integration/...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
fmt:
|
||||
gofmt -s -w .
|
||||
|
||||
up:
|
||||
docker compose up --build -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f api fraud-engine nats
|
||||
|
||||
fraud-install:
|
||||
cd fraud-engine && python3 -m venv .venv && . .venv/bin/activate && pip install -e ".[dev]"
|
||||
|
||||
fraud-test:
|
||||
cd fraud-engine && . .venv/bin/activate && python -m pytest -v
|
||||
|
||||
# Regenerates Go + Python stubs from proto/fraud/v1/fraud.proto.
|
||||
# Requires fraud-engine venv (provides bundled protoc via grpcio-tools)
|
||||
# and the Go plugins (protoc-gen-go, protoc-gen-go-grpc) on $(go env GOPATH)/bin.
|
||||
proto:
|
||||
@command -v protoc-gen-go >/dev/null || go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
|
||||
@command -v protoc-gen-go-grpc >/dev/null || go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1
|
||||
cd fraud-engine && . .venv/bin/activate && \
|
||||
PATH="$$(go env GOPATH)/bin:$$PATH" python -m grpc_tools.protoc -I ../proto \
|
||||
--go_out=.. --go_opt=module=github.com/alchemistkay/guestguard \
|
||||
--go-grpc_out=.. --go-grpc_opt=module=github.com/alchemistkay/guestguard \
|
||||
--python_out=. --pyi_out=. --grpc_python_out=. \
|
||||
../proto/fraud/v1/fraud.proto
|
||||
|
||||
clean:
|
||||
rm -rf bin fraud-engine/.venv
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /out/api ./cmd/api
|
||||
|
||||
FROM alpine:3.20 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
addgroup -g 1000 app && \
|
||||
adduser -D -u 1000 -G app app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/api /app/api
|
||||
|
||||
USER 1000:1000
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/api"]
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/api"
|
||||
"github.com/alchemistkay/guestguard/internal/config"
|
||||
"github.com/alchemistkay/guestguard/internal/fraud"
|
||||
"github.com/alchemistkay/guestguard/internal/natspub"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := newLogger(cfg.Env)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("connecting to database")
|
||||
db, err := storage.NewDB(rootCtx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
logger.Info("running migrations")
|
||||
if err := db.Migrate(rootCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("connecting to nats", "url", cfg.NATSURL)
|
||||
natsClient, err := natspub.Connect(rootCtx, cfg.NATSURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer natsClient.Close()
|
||||
|
||||
logger.Info("dialing fraud engine", "addr", cfg.FraudGRPCAddr)
|
||||
fraudClient, err := fraud.Dial(rootCtx, cfg.FraudGRPCAddr, cfg.FraudGRPCTimeout, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fraudClient.Close()
|
||||
|
||||
hub := api.NewHub(logger)
|
||||
|
||||
accessLogs := storage.NewAccessLogRepo(db)
|
||||
fraudSub, err := natspub.NewFraudScoredSubscriber(
|
||||
rootCtx, natsClient, "core-api-fraud-scored",
|
||||
func(ctx context.Context, evt natspub.FraudScored) error {
|
||||
if err := accessLogs.ApplyScore(ctx, storage.ApplyScoreParams{
|
||||
AccessLogID: evt.AccessLogID,
|
||||
Score: evt.Score,
|
||||
Reasons: evt.Reasons,
|
||||
Flagged: evt.Score >= 60,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(evt)
|
||||
hub.Broadcast(api.WSEvent{
|
||||
Type: "fraud.scored",
|
||||
EventID: evt.EventID,
|
||||
Payload: payload,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fraudConsumeCtx, err := fraudSub.Start(rootCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fraudConsumeCtx.Stop()
|
||||
|
||||
rsvpSub, err := natspub.NewRSVPConfirmedSubscriber(
|
||||
rootCtx, natsClient, "core-api-rsvp-confirmed-ws",
|
||||
func(ctx context.Context, evt natspub.RSVPConfirmed) error {
|
||||
payload, _ := json.Marshal(evt)
|
||||
hub.Broadcast(api.WSEvent{
|
||||
Type: "rsvp.confirmed",
|
||||
EventID: evt.EventID,
|
||||
Payload: payload,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsvpConsumeCtx, err := rsvpSub.Start(rootCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rsvpConsumeCtx.Stop()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: api.NewServer(api.ServerDeps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
Hub: hub,
|
||||
AccessPublisher: natsClient,
|
||||
RSVPPublisher: natsClient,
|
||||
FraudScorer: fraudClient,
|
||||
TokenTTL: cfg.TokenTTL,
|
||||
}).Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 0, // 0 lets WS connections live; per-request handlers still bound by their own ctx
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("http server starting", "addr", cfg.HTTPAddr, "env", cfg.Env)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-rootCtx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "err", err)
|
||||
return err
|
||||
}
|
||||
logger.Info("shutdown complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLogger(env string) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
if env == "development" {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /out/notifier ./cmd/notifier
|
||||
|
||||
FROM alpine:3.20 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
addgroup -g 1000 app && \
|
||||
adduser -D -u 1000 -G app app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/notifier /app/notifier
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
ENTRYPOINT ["/app/notifier"]
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/config"
|
||||
"github.com/alchemistkay/guestguard/internal/natspub"
|
||||
"github.com/alchemistkay/guestguard/internal/notification"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: levelFor(cfg.Env)}))
|
||||
slog.SetDefault(logger)
|
||||
logger = logger.With("service", "notifier")
|
||||
|
||||
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("connecting to database")
|
||||
db, err := storage.NewDB(rootCtx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
logger.Info("connecting to nats", "url", cfg.NATSURL)
|
||||
natsClient, err := natspub.Connect(rootCtx, cfg.NATSURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer natsClient.Close()
|
||||
|
||||
repo := notification.NewRepo(db)
|
||||
sender := notification.LogSender{}
|
||||
|
||||
rsvpSub, err := natspub.NewRSVPConfirmedSubscriber(
|
||||
rootCtx, natsClient, "notifier-rsvp-confirmed",
|
||||
func(ctx context.Context, evt natspub.RSVPConfirmed) error {
|
||||
return handleRSVPConfirmed(ctx, logger, repo, sender, evt)
|
||||
},
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsvpCC, err := rsvpSub.Start(rootCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rsvpCC.Stop()
|
||||
|
||||
fraudSub, err := natspub.NewFraudScoredSubscriber(
|
||||
rootCtx, natsClient, "notifier-fraud-scored",
|
||||
func(ctx context.Context, evt natspub.FraudScored) error {
|
||||
return handleFraudScored(ctx, logger, repo, sender, evt)
|
||||
},
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fraudCC, err := fraudSub.Start(rootCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fraudCC.Stop()
|
||||
|
||||
logger.Info("notifier started")
|
||||
<-rootCtx.Done()
|
||||
logger.Info("notifier shutting down")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleRSVPConfirmed(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
repo *notification.Repo,
|
||||
sender notification.Sender,
|
||||
evt natspub.RSVPConfirmed,
|
||||
) error {
|
||||
msg := notification.OutboundMessage{
|
||||
GuestID: evt.GuestID,
|
||||
Channel: notification.ChannelEmail,
|
||||
Type: notification.TypeConfirmation,
|
||||
Subject: "Your RSVP is confirmed",
|
||||
Body: "Thanks for your response.",
|
||||
Metadata: map[string]any{
|
||||
"rsvp_id": evt.RSVPID,
|
||||
"event_id": evt.EventID,
|
||||
"response": evt.Response,
|
||||
"plus_ones": evt.PlusOnes,
|
||||
"risk_score": evt.RiskScore,
|
||||
"submitted_at": evt.SubmittedAt,
|
||||
},
|
||||
}
|
||||
|
||||
providerID, sendErr := sender.Send(ctx, msg)
|
||||
status := notification.StatusSent
|
||||
errStr := ""
|
||||
if sendErr != nil {
|
||||
status = notification.StatusFailed
|
||||
errStr = sendErr.Error()
|
||||
}
|
||||
|
||||
id, err := repo.Record(ctx, notification.RecordParams{
|
||||
GuestID: evt.GuestID,
|
||||
Channel: msg.Channel,
|
||||
Type: msg.Type,
|
||||
Status: status,
|
||||
ProviderID: providerID,
|
||||
Error: errStr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("notification dispatched",
|
||||
"notification_id", id,
|
||||
"guest_id", evt.GuestID,
|
||||
"channel", msg.Channel,
|
||||
"type", msg.Type,
|
||||
"status", status,
|
||||
"provider_id", providerID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleFraudScored(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
repo *notification.Repo,
|
||||
sender notification.Sender,
|
||||
evt natspub.FraudScored,
|
||||
) error {
|
||||
// Only alert when the score crosses a meaningful threshold; low/medium
|
||||
// scores are noise for the host.
|
||||
if evt.Score < 60 {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := notification.OutboundMessage{
|
||||
GuestID: evt.GuestID,
|
||||
Channel: notification.ChannelEmail,
|
||||
Type: notification.TypeReminder, // reusing existing enum; a fraud_alert type would be nicer
|
||||
Subject: "Suspicious access attempt detected",
|
||||
Body: "A flagged access attempt occurred for one of your guests.",
|
||||
Metadata: map[string]any{
|
||||
"event_id": evt.EventID,
|
||||
"score": evt.Score,
|
||||
"risk": evt.Risk,
|
||||
"reasons": evt.Reasons,
|
||||
},
|
||||
}
|
||||
|
||||
providerID, sendErr := sender.Send(ctx, msg)
|
||||
status := notification.StatusSent
|
||||
errStr := ""
|
||||
if sendErr != nil {
|
||||
status = notification.StatusFailed
|
||||
errStr = sendErr.Error()
|
||||
}
|
||||
|
||||
id, err := repo.Record(ctx, notification.RecordParams{
|
||||
GuestID: evt.GuestID,
|
||||
Channel: msg.Channel,
|
||||
Type: msg.Type,
|
||||
Status: status,
|
||||
ProviderID: providerID,
|
||||
Error: errStr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Warn("fraud alert dispatched",
|
||||
"notification_id", id,
|
||||
"guest_id", evt.GuestID,
|
||||
"score", evt.Score,
|
||||
"risk", evt.Risk,
|
||||
"reasons", evt.Reasons,
|
||||
"status", status,
|
||||
"provider_id", providerID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func levelFor(env string) slog.Level {
|
||||
if env == "development" {
|
||||
return slog.LevelDebug
|
||||
}
|
||||
return slog.LevelInfo
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: guestguard
|
||||
POSTGRES_PASSWORD: guestguard
|
||||
POSTGRES_DB: guestguard
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U guestguard -d guestguard"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
command:
|
||||
- "-js"
|
||||
- "-sd"
|
||||
- "/data"
|
||||
- "-m"
|
||||
- "8222"
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222"
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: cmd/api/Dockerfile
|
||||
environment:
|
||||
GG_ENV: development
|
||||
GG_HTTP_ADDR: :8080
|
||||
GG_DATABASE_URL: postgres://guestguard:guestguard@postgres:5432/guestguard?sslmode=disable
|
||||
GG_NATS_URL: nats://nats:4222
|
||||
GG_FRAUD_GRPC_ADDR: fraud-engine:9091
|
||||
GG_FRAUD_GRPC_TIMEOUT: 250ms
|
||||
GG_TOKEN_SECRET: dev-only-insecure-secret-change-me
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
fraud-engine:
|
||||
build:
|
||||
context: ./fraud-engine
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
GG_ENV: development
|
||||
GG_HTTP_ADDR: 0.0.0.0:8081
|
||||
GG_GRPC_ADDR: 0.0.0.0:9091
|
||||
GG_NATS_URL: nats://nats:4222
|
||||
ports:
|
||||
- "8081:8081"
|
||||
- "9091:9091"
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
notifier:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: cmd/notifier/Dockerfile
|
||||
environment:
|
||||
GG_ENV: development
|
||||
GG_DATABASE_URL: postgres://guestguard:guestguard@postgres:5432/guestguard?sslmode=disable
|
||||
GG_NATS_URL: nats://nats:4222
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NUXT_PUBLIC_API_BASE: http://localhost:8080
|
||||
NUXT_PUBLIC_WS_BASE: ws://localhost:8080
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
nats-data:
|
||||
@@ -0,0 +1,12 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv
|
||||
.env
|
||||
.env.local
|
||||
tests
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM python:3.12-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --upgrade pip && pip install .
|
||||
|
||||
COPY app ./app
|
||||
COPY fraud ./fraud
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=build /usr/local/bin /usr/local/bin
|
||||
COPY --from=build /app/app /app/app
|
||||
COPY --from=build /app/fraud /app/fraud
|
||||
|
||||
USER 1000:1000
|
||||
EXPOSE 8081 9091
|
||||
|
||||
CMD ["python", "-m", "app.main"]
|
||||
@@ -0,0 +1,28 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="GG_", env_file=".env", extra="ignore")
|
||||
|
||||
env: str = Field(default="development")
|
||||
http_addr: str = Field(default="0.0.0.0:8081")
|
||||
grpc_addr: str = Field(default="0.0.0.0:9091")
|
||||
nats_url: str = Field(default="nats://localhost:4222")
|
||||
stream_name: str = Field(default="GUESTGUARD")
|
||||
consumer_durable: str = Field(default="fraud-engine-access")
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self.http_addr.split(":", 1)[0] or "0.0.0.0"
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
parts = self.http_addr.rsplit(":", 1)
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return 8081
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from nats.aio.msg import Msg
|
||||
|
||||
from app.nats_bus import NatsBus
|
||||
from app.schemas import AccessAttempted, FraudScored
|
||||
from app.scoring import HeuristicScorer, risk_band
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUBJECT_ACCESS_ATTEMPTED = "guest.access.attempted"
|
||||
SUBJECT_FRAUD_SCORED = "fraud.scored"
|
||||
|
||||
|
||||
class FraudConsumer:
|
||||
def __init__(self, bus: NatsBus, durable: str, scorer: HeuristicScorer) -> None:
|
||||
self._bus = bus
|
||||
self._durable = durable
|
||||
self._scorer = scorer
|
||||
self._subscription = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._subscription = await self._bus.subscribe(
|
||||
subject=SUBJECT_ACCESS_ATTEMPTED,
|
||||
durable=self._durable,
|
||||
handler=self._handle,
|
||||
manual_ack=True,
|
||||
)
|
||||
logger.info("subscribed", extra={"subject": SUBJECT_ACCESS_ATTEMPTED, "durable": self._durable})
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._subscription is not None:
|
||||
await self._subscription.unsubscribe()
|
||||
self._subscription = None
|
||||
|
||||
async def _handle(self, msg: Msg) -> None:
|
||||
try:
|
||||
evt = AccessAttempted.model_validate_json(msg.data)
|
||||
except Exception:
|
||||
logger.exception("invalid access.attempted payload — terminating message")
|
||||
await msg.term()
|
||||
return
|
||||
|
||||
try:
|
||||
result = self._scorer.score(evt)
|
||||
scored = FraudScored(
|
||||
event_id=evt.event_id,
|
||||
guest_id=evt.guest_id,
|
||||
token_id=evt.token_id,
|
||||
access_log_id=evt.access_log_id,
|
||||
score=result.score,
|
||||
risk=risk_band(result.score),
|
||||
reasons=result.reasons,
|
||||
scored_at=datetime.now(UTC),
|
||||
)
|
||||
await self._bus.publish(
|
||||
SUBJECT_FRAUD_SCORED,
|
||||
scored.model_dump_json().encode("utf-8"),
|
||||
)
|
||||
logger.info(
|
||||
"scored access",
|
||||
extra={
|
||||
"guest_id": str(evt.guest_id),
|
||||
"score": result.score,
|
||||
"risk": scored.risk,
|
||||
},
|
||||
)
|
||||
await msg.ack()
|
||||
except Exception:
|
||||
logger.exception("failed to score access — nak")
|
||||
await msg.nak(delay=2)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import grpc
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
from app.scoring import BLOCK, HIGH, LOW, MEDIUM, HeuristicScorer, risk_band
|
||||
from fraud.v1 import fraud_pb2, fraud_pb2_grpc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RISK_TO_PROTO = {
|
||||
LOW: fraud_pb2.RISK_LOW,
|
||||
MEDIUM: fraud_pb2.RISK_MEDIUM,
|
||||
HIGH: fraud_pb2.RISK_HIGH,
|
||||
BLOCK: fraud_pb2.RISK_BLOCK,
|
||||
}
|
||||
|
||||
|
||||
class FraudServicer(fraud_pb2_grpc.FraudServiceServicer):
|
||||
def __init__(self, scorer: HeuristicScorer) -> None:
|
||||
self._scorer = scorer
|
||||
|
||||
async def Score( # noqa: N802 — gRPC method
|
||||
self,
|
||||
request: fraud_pb2.ScoreRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> fraud_pb2.ScoreResponse:
|
||||
try:
|
||||
evt = AccessAttempted(
|
||||
event_id=UUID(request.event_id),
|
||||
guest_id=UUID(request.guest_id),
|
||||
token_id=UUID(request.token_id),
|
||||
access_log_id=UUID(request.access_log_id) if request.access_log_id else UUID(int=0),
|
||||
fingerprint=dict(request.fingerprint) if request.fingerprint else None,
|
||||
ip_address=request.ip_address or None,
|
||||
user_agent=request.user_agent or None,
|
||||
referrer=request.referrer or None,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, f"bad request: {exc}")
|
||||
raise # unreachable, abort raises
|
||||
|
||||
result = self._scorer.score(evt)
|
||||
band = risk_band(result.score)
|
||||
return fraud_pb2.ScoreResponse(
|
||||
score=result.score,
|
||||
risk=_RISK_TO_PROTO.get(band, fraud_pb2.RISK_UNSPECIFIED),
|
||||
reasons=result.reasons,
|
||||
)
|
||||
|
||||
|
||||
async def serve_grpc(scorer: HeuristicScorer, addr: str) -> grpc.aio.Server:
|
||||
server = grpc.aio.server()
|
||||
fraud_pb2_grpc.add_FraudServiceServicer_to_server(FraudServicer(scorer), server)
|
||||
server.add_insecure_port(addr)
|
||||
await server.start()
|
||||
logger.info("grpc server started", extra={"addr": addr})
|
||||
return server
|
||||
|
||||
|
||||
async def stop_grpc(server: grpc.aio.Server) -> None:
|
||||
await server.stop(grace=2.0)
|
||||
await asyncio.sleep(0)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import load_settings
|
||||
from app.consumer import FraudConsumer
|
||||
from app.grpc_server import serve_grpc, stop_grpc
|
||||
from app.nats_bus import NatsBus
|
||||
from app.scoring import HeuristicScorer
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
def _configure_logging(env: str) -> None:
|
||||
level = logging.DEBUG if env == "development" else logging.INFO
|
||||
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
_configure_logging(settings.env)
|
||||
|
||||
bus = NatsBus(settings.nats_url, settings.stream_name)
|
||||
await bus.connect()
|
||||
|
||||
scorer = HeuristicScorer()
|
||||
consumer = FraudConsumer(bus, settings.consumer_durable, scorer)
|
||||
await consumer.start()
|
||||
|
||||
grpc_server = await serve_grpc(scorer, settings.grpc_addr)
|
||||
|
||||
app.state.bus = bus
|
||||
app.state.consumer = consumer
|
||||
app.state.scorer = scorer
|
||||
app.state.grpc = grpc_server
|
||||
app.state.settings = settings
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_grpc(grpc_server)
|
||||
await consumer.stop()
|
||||
await bus.close()
|
||||
|
||||
|
||||
app = FastAPI(title="GuestGuard Fraud Engine", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready() -> dict[str, str]:
|
||||
bus = getattr(app.state, "bus", None)
|
||||
if bus is None:
|
||||
return {"status": "starting"}
|
||||
return {"status": "ok", "nats": "up"}
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
settings = load_settings()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import nats
|
||||
from nats.aio.client import Client as NATSClient
|
||||
from nats.js import JetStreamContext
|
||||
from nats.js.api import ConsumerConfig, DeliverPolicy, RetentionPolicy, StorageType, StreamConfig
|
||||
from nats.js.errors import NotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_SUBJECTS = ["guest.>", "fraud.>", "rsvp.>", "invitation.>"]
|
||||
|
||||
|
||||
class NatsBus:
|
||||
def __init__(self, url: str, stream_name: str) -> None:
|
||||
self._url = url
|
||||
self._stream_name = stream_name
|
||||
self._nc: NATSClient | None = None
|
||||
self._js: JetStreamContext | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._nc = await nats.connect(
|
||||
self._url,
|
||||
name="guestguard-fraud-engine",
|
||||
max_reconnect_attempts=-1,
|
||||
reconnect_time_wait=2,
|
||||
)
|
||||
self._js = self._nc.jetstream()
|
||||
await self._ensure_stream()
|
||||
logger.info("connected to nats", extra={"url": self._url})
|
||||
|
||||
async def _ensure_stream(self) -> None:
|
||||
assert self._js is not None
|
||||
try:
|
||||
await self._js.stream_info(self._stream_name)
|
||||
return
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
cfg = StreamConfig(
|
||||
name=self._stream_name,
|
||||
subjects=STREAM_SUBJECTS,
|
||||
retention=RetentionPolicy.LIMITS,
|
||||
storage=StorageType.FILE,
|
||||
max_age=14 * 24 * 60 * 60 * 1_000_000_000, # 14 days, ns
|
||||
)
|
||||
await self._js.add_stream(config=cfg)
|
||||
|
||||
@property
|
||||
def js(self) -> JetStreamContext:
|
||||
if self._js is None:
|
||||
raise RuntimeError("nats not connected")
|
||||
return self._js
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
subject: str,
|
||||
durable: str,
|
||||
handler,
|
||||
manual_ack: bool = True,
|
||||
):
|
||||
cfg = ConsumerConfig(
|
||||
durable_name=durable,
|
||||
ack_policy="explicit",
|
||||
deliver_policy=DeliverPolicy.ALL,
|
||||
max_deliver=5,
|
||||
ack_wait=30,
|
||||
filter_subject=subject,
|
||||
)
|
||||
return await self.js.subscribe(
|
||||
subject=subject,
|
||||
durable=durable,
|
||||
cb=handler,
|
||||
manual_ack=manual_ack,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
async def publish(self, subject: str, payload: bytes) -> None:
|
||||
await self.js.publish(subject, payload)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._nc is not None:
|
||||
await self._nc.drain()
|
||||
await asyncio.sleep(0)
|
||||
self._nc = None
|
||||
self._js = None
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AccessAttempted(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
event_id: UUID
|
||||
guest_id: UUID
|
||||
token_id: UUID
|
||||
access_log_id: UUID
|
||||
fingerprint: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
referrer: str | None = None
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
class FraudScored(BaseModel):
|
||||
event_id: UUID
|
||||
guest_id: UUID
|
||||
token_id: UUID
|
||||
access_log_id: UUID
|
||||
score: int = Field(ge=0, le=100)
|
||||
risk: str
|
||||
reasons: list[str]
|
||||
scored_at: datetime
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Heuristic risk scoring.
|
||||
|
||||
This is intentionally simple — a weighted feature scorer. Each feature returns
|
||||
a 0-100 sub-score; the overall score is a weighted sum. We keep memory of seen
|
||||
fingerprints per guest so subsequent accesses can be compared against the
|
||||
baseline established by the first one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
def risk_band(score: int) -> str:
|
||||
if score <= 30:
|
||||
return LOW
|
||||
if score <= 60:
|
||||
return MEDIUM
|
||||
if score <= 85:
|
||||
return HIGH
|
||||
return BLOCK
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuestBaseline:
|
||||
fingerprint_digest: str | None = None
|
||||
ip_prefix: str | None = None
|
||||
accesses: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoringResult:
|
||||
score: int
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeuristicScorer:
|
||||
weights: dict[str, float] = field(
|
||||
default_factory=lambda: {
|
||||
"fingerprint_mismatch": 0.40,
|
||||
"ip_change": 0.25,
|
||||
"missing_signals": 0.10,
|
||||
"repeated_access": 0.10,
|
||||
"no_user_agent": 0.15,
|
||||
}
|
||||
)
|
||||
baselines: dict[UUID, GuestBaseline] = field(default_factory=dict)
|
||||
|
||||
def score(self, evt: AccessAttempted) -> ScoringResult:
|
||||
reasons: list[str] = []
|
||||
sub: dict[str, int] = {}
|
||||
|
||||
baseline = self.baselines.get(evt.guest_id, GuestBaseline())
|
||||
current_digest = _fingerprint_digest(evt.fingerprint)
|
||||
current_prefix = _ip_prefix(evt.ip_address)
|
||||
|
||||
if baseline.fingerprint_digest is None:
|
||||
sub["fingerprint_mismatch"] = 0
|
||||
elif baseline.fingerprint_digest == current_digest:
|
||||
sub["fingerprint_mismatch"] = 0
|
||||
else:
|
||||
sub["fingerprint_mismatch"] = 100
|
||||
reasons.append("fingerprint differs from baseline")
|
||||
|
||||
if baseline.ip_prefix is None:
|
||||
sub["ip_change"] = 0
|
||||
elif baseline.ip_prefix == current_prefix:
|
||||
sub["ip_change"] = 0
|
||||
else:
|
||||
sub["ip_change"] = 80
|
||||
reasons.append("ip address changed since first access")
|
||||
|
||||
if not evt.fingerprint:
|
||||
sub["missing_signals"] = 70
|
||||
reasons.append("no device fingerprint provided")
|
||||
else:
|
||||
sub["missing_signals"] = 0
|
||||
|
||||
sub["repeated_access"] = min(baseline.accesses * 10, 60)
|
||||
if baseline.accesses >= 5:
|
||||
reasons.append(f"token accessed {baseline.accesses + 1} times")
|
||||
|
||||
if not evt.user_agent:
|
||||
sub["no_user_agent"] = 80
|
||||
reasons.append("missing user agent")
|
||||
else:
|
||||
sub["no_user_agent"] = 0
|
||||
|
||||
weighted = sum(sub[k] * self.weights[k] for k in self.weights)
|
||||
final = int(round(min(max(weighted, 0), 100)))
|
||||
|
||||
# Update baseline AFTER scoring so the first access sets it without
|
||||
# being penalised against itself.
|
||||
if baseline.fingerprint_digest is None:
|
||||
baseline.fingerprint_digest = current_digest
|
||||
if baseline.ip_prefix is None:
|
||||
baseline.ip_prefix = current_prefix
|
||||
baseline.accesses += 1
|
||||
self.baselines[evt.guest_id] = baseline
|
||||
|
||||
return ScoringResult(score=final, reasons=reasons)
|
||||
|
||||
|
||||
def _fingerprint_digest(fp: dict[str, Any] | None) -> str | None:
|
||||
if not fp:
|
||||
return None
|
||||
items = sorted((str(k), str(v)) for k, v in fp.items())
|
||||
h = hashlib.sha256()
|
||||
for k, v in items:
|
||||
h.update(k.encode())
|
||||
h.update(b"=")
|
||||
h.update(v.encode())
|
||||
h.update(b";")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _ip_prefix(ip: str | None) -> str | None:
|
||||
if not ip:
|
||||
return None
|
||||
if ":" in ip:
|
||||
# IPv6 — keep first 4 hextets
|
||||
parts = ip.split(":")[:4]
|
||||
return ":".join(parts)
|
||||
parts = ip.split(".")
|
||||
if len(parts) == 4:
|
||||
return ".".join(parts[:3])
|
||||
return ip
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: fraud/v1/fraud.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
'fraud/v1/fraud.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14\x66raud/v1/fraud.proto\x12\x13guestguard.fraud.v1\"\x92\x02\n\x0cScoreRequest\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x10\n\x08guest_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12\x15\n\raccess_log_id\x18\x04 \x01(\t\x12G\n\x0b\x66ingerprint\x18\x05 \x03(\x0b\x32\x32.guestguard.fraud.v1.ScoreRequest.FingerprintEntry\x12\x12\n\nip_address\x18\x06 \x01(\t\x12\x12\n\nuser_agent\x18\x07 \x01(\t\x12\x10\n\x08referrer\x18\x08 \x01(\t\x1a\x32\n\x10\x46ingerprintEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"X\n\rScoreResponse\x12\r\n\x05score\x18\x01 \x01(\x05\x12\'\n\x04risk\x18\x02 \x01(\x0e\x32\x19.guestguard.fraud.v1.Risk\x12\x0f\n\x07reasons\x18\x03 \x03(\t*Z\n\x04Risk\x12\x14\n\x10RISK_UNSPECIFIED\x10\x00\x12\x0c\n\x08RISK_LOW\x10\x01\x12\x0f\n\x0bRISK_MEDIUM\x10\x02\x12\r\n\tRISK_HIGH\x10\x03\x12\x0e\n\nRISK_BLOCK\x10\x04\x32^\n\x0c\x46raudService\x12N\n\x05Score\x12!.guestguard.fraud.v1.ScoreRequest\x1a\".guestguard.fraud.v1.ScoreResponseB=Z;github.com/alchemistkay/guestguard/internal/fraudpb;fraudpbb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'fraud.v1.fraud_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['DESCRIPTOR']._loaded_options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'Z;github.com/alchemistkay/guestguard/internal/fraudpb;fraudpb'
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._loaded_options = None
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_options = b'8\001'
|
||||
_globals['_RISK']._serialized_start=412
|
||||
_globals['_RISK']._serialized_end=502
|
||||
_globals['_SCOREREQUEST']._serialized_start=46
|
||||
_globals['_SCOREREQUEST']._serialized_end=320
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_start=270
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_end=320
|
||||
_globals['_SCORERESPONSE']._serialized_start=322
|
||||
_globals['_SCORERESPONSE']._serialized_end=410
|
||||
_globals['_FRAUDSERVICE']._serialized_start=504
|
||||
_globals['_FRAUDSERVICE']._serialized_end=598
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,58 @@
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Risk(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
RISK_UNSPECIFIED: _ClassVar[Risk]
|
||||
RISK_LOW: _ClassVar[Risk]
|
||||
RISK_MEDIUM: _ClassVar[Risk]
|
||||
RISK_HIGH: _ClassVar[Risk]
|
||||
RISK_BLOCK: _ClassVar[Risk]
|
||||
RISK_UNSPECIFIED: Risk
|
||||
RISK_LOW: Risk
|
||||
RISK_MEDIUM: Risk
|
||||
RISK_HIGH: Risk
|
||||
RISK_BLOCK: Risk
|
||||
|
||||
class ScoreRequest(_message.Message):
|
||||
__slots__ = ("event_id", "guest_id", "token_id", "access_log_id", "fingerprint", "ip_address", "user_agent", "referrer")
|
||||
class FingerprintEntry(_message.Message):
|
||||
__slots__ = ("key", "value")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: str
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
|
||||
EVENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
GUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
TOKEN_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
ACCESS_LOG_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
FINGERPRINT_FIELD_NUMBER: _ClassVar[int]
|
||||
IP_ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
USER_AGENT_FIELD_NUMBER: _ClassVar[int]
|
||||
REFERRER_FIELD_NUMBER: _ClassVar[int]
|
||||
event_id: str
|
||||
guest_id: str
|
||||
token_id: str
|
||||
access_log_id: str
|
||||
fingerprint: _containers.ScalarMap[str, str]
|
||||
ip_address: str
|
||||
user_agent: str
|
||||
referrer: str
|
||||
def __init__(self, event_id: _Optional[str] = ..., guest_id: _Optional[str] = ..., token_id: _Optional[str] = ..., access_log_id: _Optional[str] = ..., fingerprint: _Optional[_Mapping[str, str]] = ..., ip_address: _Optional[str] = ..., user_agent: _Optional[str] = ..., referrer: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class ScoreResponse(_message.Message):
|
||||
__slots__ = ("score", "risk", "reasons")
|
||||
SCORE_FIELD_NUMBER: _ClassVar[int]
|
||||
RISK_FIELD_NUMBER: _ClassVar[int]
|
||||
REASONS_FIELD_NUMBER: _ClassVar[int]
|
||||
score: int
|
||||
risk: Risk
|
||||
reasons: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, score: _Optional[int] = ..., risk: _Optional[_Union[Risk, str]] = ..., reasons: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
from fraud.v1 import fraud_pb2 as fraud_dot_v1_dot_fraud__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in fraud/v1/fraud_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class FraudServiceStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Score = channel.unary_unary(
|
||||
'/guestguard.fraud.v1.FraudService/Score',
|
||||
request_serializer=fraud_dot_v1_dot_fraud__pb2.ScoreRequest.SerializeToString,
|
||||
response_deserializer=fraud_dot_v1_dot_fraud__pb2.ScoreResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class FraudServiceServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def Score(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_FraudServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Score': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Score,
|
||||
request_deserializer=fraud_dot_v1_dot_fraud__pb2.ScoreRequest.FromString,
|
||||
response_serializer=fraud_dot_v1_dot_fraud__pb2.ScoreResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'guestguard.fraud.v1.FraudService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('guestguard.fraud.v1.FraudService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class FraudService(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def Score(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/guestguard.fraud.v1.FraudService/Score',
|
||||
fraud_dot_v1_dot_fraud__pb2.ScoreRequest.SerializeToString,
|
||||
fraud_dot_v1_dot_fraud__pb2.ScoreResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "guestguard-fraud-engine"
|
||||
version = "0.1.0"
|
||||
description = "GuestGuard fraud detection engine"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.5",
|
||||
"nats-py>=2.9",
|
||||
"structlog>=24.4",
|
||||
"grpcio>=1.66",
|
||||
"protobuf>=5.28",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.7",
|
||||
"grpcio-tools>=1.66",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*", "fraud*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "SIM"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Integration test for the gRPC FraudService over an in-process channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
|
||||
from app.grpc_server import FraudServicer, serve_grpc, stop_grpc
|
||||
from app.scoring import HeuristicScorer
|
||||
from fraud.v1 import fraud_pb2, fraud_pb2_grpc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_low_risk_first_access():
|
||||
scorer = HeuristicScorer()
|
||||
server = await serve_grpc(scorer, "127.0.0.1:0")
|
||||
# add_insecure_port returns 0 so we need to fish out the actual bound port via _server's state.
|
||||
# Easier: rebind on a known free port for the test.
|
||||
await stop_grpc(server)
|
||||
|
||||
addr = "127.0.0.1:50951"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
resp = await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=str(uuid4()),
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "Chrome", "platform": "macOS"},
|
||||
ip_address="203.0.113.7",
|
||||
user_agent="Mozilla/5.0",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert resp.score <= 30
|
||||
assert resp.risk == fraud_pb2.RISK_LOW
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_high_risk_after_baseline_change():
|
||||
scorer = HeuristicScorer()
|
||||
addr = "127.0.0.1:50952"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
guest_id = str(uuid4())
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
|
||||
await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=guest_id,
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "Chrome"},
|
||||
ip_address="203.0.113.7",
|
||||
user_agent="Mozilla/5.0",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
resp = await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=guest_id,
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "curl/8"},
|
||||
ip_address="198.51.100.42",
|
||||
user_agent="",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert resp.score >= 60
|
||||
assert resp.risk in {fraud_pb2.RISK_HIGH, fraud_pb2.RISK_BLOCK}
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_uuid_returns_invalid_argument():
|
||||
scorer = HeuristicScorer()
|
||||
addr = "127.0.0.1:50953"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
with pytest.raises(grpc.RpcError) as excinfo:
|
||||
await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id="not-a-uuid",
|
||||
guest_id=str(uuid4()),
|
||||
token_id=str(uuid4()),
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
def test_servicer_constructs():
|
||||
# Ensures the servicer wires up against the generated stub.
|
||||
FraudServicer(HeuristicScorer())
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
from app.scoring import HeuristicScorer, risk_band
|
||||
|
||||
|
||||
def _evt(
|
||||
*,
|
||||
guest_id=None,
|
||||
fingerprint=None,
|
||||
ip=None,
|
||||
user_agent="Mozilla/5.0",
|
||||
):
|
||||
return AccessAttempted(
|
||||
event_id=uuid4(),
|
||||
guest_id=guest_id or uuid4(),
|
||||
token_id=uuid4(),
|
||||
access_log_id=uuid4(),
|
||||
fingerprint=fingerprint,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_first_access_with_full_signals_is_low_risk():
|
||||
scorer = HeuristicScorer()
|
||||
evt = _evt(
|
||||
fingerprint={"ua": "Chrome", "platform": "macOS"},
|
||||
ip="203.0.113.7",
|
||||
)
|
||||
res = scorer.score(evt)
|
||||
assert res.score <= 30
|
||||
assert risk_band(res.score) == "low"
|
||||
|
||||
|
||||
def test_fingerprint_change_drives_score_up():
|
||||
scorer = HeuristicScorer()
|
||||
guest = uuid4()
|
||||
first = _evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7")
|
||||
scorer.score(first)
|
||||
|
||||
second = _evt(guest_id=guest, fingerprint={"ua": "Safari"}, ip="203.0.113.7")
|
||||
res = scorer.score(second)
|
||||
|
||||
assert res.score >= 40
|
||||
assert any("fingerprint" in r for r in res.reasons)
|
||||
|
||||
|
||||
def test_ip_change_and_fingerprint_change_classify_high_or_block():
|
||||
scorer = HeuristicScorer()
|
||||
guest = uuid4()
|
||||
scorer.score(_evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7"))
|
||||
|
||||
suspicious = _evt(
|
||||
guest_id=guest,
|
||||
fingerprint={"ua": "Curl/8"},
|
||||
ip="198.51.100.42",
|
||||
user_agent=None,
|
||||
)
|
||||
res = scorer.score(suspicious)
|
||||
|
||||
assert res.score >= 60
|
||||
assert risk_band(res.score) in {"high", "block"}
|
||||
|
||||
|
||||
def test_missing_fingerprint_and_user_agent_flagged():
|
||||
scorer = HeuristicScorer()
|
||||
res = scorer.score(_evt(fingerprint=None, ip="203.0.113.1", user_agent=None))
|
||||
assert "no device fingerprint provided" in res.reasons
|
||||
assert "missing user agent" in res.reasons
|
||||
|
||||
|
||||
def test_score_clamped_to_0_100():
|
||||
scorer = HeuristicScorer()
|
||||
# 10 successive accesses with no fingerprint, no UA, changing IPs
|
||||
guest = uuid4()
|
||||
for i in range(12):
|
||||
res = scorer.score(_evt(guest_id=guest, fingerprint=None, ip=f"10.0.{i}.1", user_agent=None))
|
||||
assert 0 <= res.score <= 100
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
NITRO_HOST=0.0.0.0 \
|
||||
NITRO_PORT=3000
|
||||
WORKDIR /app
|
||||
|
||||
# node:20-alpine ships a `node` user at uid/gid 1000 already.
|
||||
COPY --from=build --chown=node:node /app/.output ./.output
|
||||
|
||||
# Workaround for Nitro 2.12.4: the public-asset manifest declares paths
|
||||
# relative to .output/server/, but the runtime resolves them relative to
|
||||
# .output/server/chunks/nitro/, so it looks under chunks/public/. Symlink
|
||||
# that location to the real public dir so `node .output/server/index.mjs`
|
||||
# can actually find _nuxt/*.css and *.js.
|
||||
RUN ln -sfn ../../public /app/.output/server/chunks/public
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
const { host, clear } = useHost()
|
||||
const route = useRoute()
|
||||
|
||||
// GitHub icon is a "marketing" affordance — only show it on the public landing
|
||||
// page. Inside the app it just clutters the chrome.
|
||||
const showGithub = computed(() => route.path === '/')
|
||||
|
||||
function logout() {
|
||||
clear()
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
|
||||
<header class="border-b border-zinc-900">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-full bg-brand-500"></span>
|
||||
GuestGuard
|
||||
</NuxtLink>
|
||||
<nav class="flex items-center gap-4 text-sm text-zinc-400">
|
||||
<a
|
||||
v-if="showGithub"
|
||||
href="https://github.com/alchemistkay/guestguard"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="transition hover:text-zinc-100"
|
||||
title="View on GitHub"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<template v-if="host">
|
||||
<button class="transition hover:text-zinc-100" @click="logout">Sign out</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink to="/dashboard" class="transition hover:text-zinc-100">Sign in</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-6 py-8">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
|
||||
<footer class="mt-16 border-t border-zinc-900">
|
||||
<div class="mx-auto max-w-6xl px-6 py-6 text-xs text-zinc-500">
|
||||
© 2025 GuestGuard — Hassle-free RSVPs for every occasion.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #09090b; /* zinc-950 */
|
||||
color: #fafafa; /* zinc-50 */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-500 text-zinc-950 hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-400;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn text-zinc-300 hover:bg-zinc-800;
|
||||
}
|
||||
.input {
|
||||
@apply block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm
|
||||
text-zinc-100 placeholder-zinc-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-sm;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-400;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
.badge-low { @apply badge bg-brand-900/40 text-brand-300; }
|
||||
.badge-medium { @apply badge bg-yellow-900/40 text-yellow-300; }
|
||||
.badge-high { @apply badge bg-orange-900/40 text-orange-300; }
|
||||
.badge-block { @apply badge bg-red-900/50 text-red-300; }
|
||||
}
|
||||
|
||||
/* Shared float animations for mockup cards (rotation baked in so the cards
|
||||
keep their tilt while gently bobbing). */
|
||||
@keyframes gg-float-cw {
|
||||
0%, 100% { transform: translateY(0) rotate(3deg); }
|
||||
50% { transform: translateY(-7px) rotate(3deg); }
|
||||
}
|
||||
@keyframes gg-float-cw-sm {
|
||||
0%, 100% { transform: translateY(0) rotate(2deg); }
|
||||
50% { transform: translateY(-5px) rotate(2deg); }
|
||||
}
|
||||
@keyframes gg-float-ccw {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-9px) rotate(-2deg); }
|
||||
}
|
||||
@keyframes gg-float-ccw-sm {
|
||||
0%, 100% { transform: translateY(0) rotate(-3deg); }
|
||||
50% { transform: translateY(-6px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
@keyframes gg-ping {
|
||||
0%, 100% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.6); opacity: 0.3; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Typed wrapper around $fetch with the configured API base.
|
||||
// Usage: const events = await useApi<EventList>('/events')
|
||||
export async function useApi<T = unknown>(
|
||||
path: string,
|
||||
opts: { method?: string; body?: unknown; query?: Record<string, unknown> } = {},
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBase as string
|
||||
return await $fetch<T>(path, {
|
||||
baseURL: base,
|
||||
method: (opts.method ?? 'GET') as any,
|
||||
body: opts.body,
|
||||
query: opts.query,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Subscribes to /ws/events/:id and emits per-message callbacks.
|
||||
//
|
||||
// Auto-reconnects with exponential backoff up to 30s. Returns a cleanup
|
||||
// fn the caller invokes (e.g. inside onUnmounted).
|
||||
|
||||
interface WSMessage {
|
||||
type: string
|
||||
event_id: string
|
||||
payload: any
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function useEventWS(eventId: string, onMessage: (msg: WSMessage) => void) {
|
||||
if (import.meta.server) return () => {}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const base = (config.public.wsBase as string) || ''
|
||||
const url = `${base}/ws/events/${eventId}`
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let attempt = 0
|
||||
let stopped = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function connect() {
|
||||
if (stopped) return
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0
|
||||
}
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data) as WSMessage
|
||||
onMessage(msg)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (stopped) return
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, attempt++))
|
||||
reconnectTimer = setTimeout(connect, backoff)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return function stop() {
|
||||
stopped = true
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Lightweight browser fingerprint. Not a serious anti-fraud signal on its
|
||||
// own — the value is the *delta* between accesses, which is what the fraud
|
||||
// engine actually compares against.
|
||||
export function useFingerprint(): Record<string, string | number> {
|
||||
if (import.meta.server) return {}
|
||||
|
||||
const screen = window.screen
|
||||
return {
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
languages: (navigator.languages || []).join(','),
|
||||
user_agent: navigator.userAgent,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone_offset: new Date().getTimezoneOffset(),
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
color_depth: screen.colorDepth,
|
||||
pixel_ratio: window.devicePixelRatio,
|
||||
cookie_enabled: navigator.cookieEnabled ? 1 : 0,
|
||||
hardware_concurrency: navigator.hardwareConcurrency || 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Demo-grade host bootstrap. Real auth would replace this entirely; for now
|
||||
// we upsert by email and stash the host id in localStorage.
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gg.host'
|
||||
|
||||
export function useHost() {
|
||||
const host = useState<User | null>('gg-host', () => null)
|
||||
|
||||
if (import.meta.client && !host.value) {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
host.value = JSON.parse(raw)
|
||||
} catch {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(email: string, name: string) {
|
||||
const u = await useApi<User>('/users', {
|
||||
method: 'POST',
|
||||
body: { email, name },
|
||||
})
|
||||
host.value = u
|
||||
if (import.meta.client) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(u))
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
function clear() {
|
||||
host.value = null
|
||||
if (import.meta.client) window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { host, bootstrap, clear }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-09-01',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
app: {
|
||||
head: {
|
||||
title: 'GuestGuard',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'description', content: 'Real-time fraud detection for event RSVPs.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
// For local dev across origins. In prod this should be empty so all
|
||||
// requests go through same-origin via an ingress proxy.
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8080',
|
||||
wsBase: process.env.NUXT_PUBLIC_WS_BASE || 'ws://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
Generated
+14496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "guestguard-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --host 0.0.0.0",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview",
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^3.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.1",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
<script setup lang="ts">
|
||||
interface Guest {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
plus_ones: number
|
||||
created_at: string
|
||||
rsvp_response?: 'attending' | 'declined' | 'maybe' | null
|
||||
rsvp_plus_ones?: number | null
|
||||
rsvp_risk_score?: number | null
|
||||
rsvp_submitted_at?: string | null
|
||||
has_token?: boolean
|
||||
}
|
||||
|
||||
interface GuestStats {
|
||||
total: number
|
||||
attending: number
|
||||
declined: number
|
||||
maybe: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
interface EventDetail {
|
||||
id: string
|
||||
host_id: string
|
||||
name: string
|
||||
slug: string
|
||||
event_date: string
|
||||
venue: string
|
||||
max_capacity: number
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface IssuedToken {
|
||||
token: string
|
||||
token_id: string
|
||||
}
|
||||
|
||||
interface WSMessage {
|
||||
type: string
|
||||
event_id: string
|
||||
payload: any
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const eventId = route.params.id as string
|
||||
|
||||
const event = ref<EventDetail | null>(null)
|
||||
const guests = ref<Guest[]>([])
|
||||
const stats = ref<GuestStats>({ total: 0, attending: 0, declined: 0, maybe: 0, pending: 0 })
|
||||
const filter = ref<'all' | 'attending' | 'declined' | 'maybe' | 'pending'>('all')
|
||||
const loading = ref(true)
|
||||
|
||||
async function refresh() {
|
||||
const [evt, list] = await Promise.all([
|
||||
useApi<EventDetail>(`/events/${eventId}`),
|
||||
useApi<{ guests: Guest[]; stats: GuestStats }>(`/events/${eventId}/guests`),
|
||||
])
|
||||
event.value = evt
|
||||
guests.value = list.guests || []
|
||||
if (list.stats) stats.value = list.stats
|
||||
}
|
||||
|
||||
const filteredGuests = computed(() => {
|
||||
if (filter.value === 'all') return guests.value
|
||||
if (filter.value === 'pending') return guests.value.filter((g) => !g.rsvp_response)
|
||||
return guests.value.filter((g) => g.rsvp_response === filter.value)
|
||||
})
|
||||
|
||||
interface ActivityItem {
|
||||
type: 'rsvp' | 'access_check'
|
||||
ts: string
|
||||
guest_id: string
|
||||
guest_name: string
|
||||
// RSVP
|
||||
response?: string
|
||||
plus_ones?: number
|
||||
// Access check
|
||||
score?: number
|
||||
band?: string
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refresh()
|
||||
// Pull history from the activity endpoint. The WebSocket hub only
|
||||
// broadcasts future events, so without this catch-up the monitor
|
||||
// is blank until a guest does something while the dashboard is open.
|
||||
backfillFeed()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function backfillFeed() {
|
||||
if (feed.value.length > 0) return // don't clobber WS items that arrived first
|
||||
try {
|
||||
const res = await useApi<{ activity: ActivityItem[] }>(
|
||||
`/events/${eventId}/activity?limit=50`,
|
||||
)
|
||||
feed.value = (res.activity || []).map(activityToFeedItem)
|
||||
} catch (e) {
|
||||
console.error('activity backfill failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function activityToFeedItem(a: ActivityItem): FeedItem {
|
||||
if (a.type === 'rsvp') {
|
||||
return {
|
||||
type: 'rsvp.confirmed',
|
||||
ts: a.ts,
|
||||
text: `${a.guest_name} → ${a.response} (+${a.plus_ones || 0})`,
|
||||
}
|
||||
}
|
||||
// access_check — friendly text per band, matching the live-WS handler.
|
||||
const band = a.band || 'low'
|
||||
const who = a.guest_name || 'A guest'
|
||||
const friendlyText: Record<string, string> = {
|
||||
low: `${who} opened their invitation — looks normal.`,
|
||||
medium: `${who}'s access looks a bit unusual — worth a glance.`,
|
||||
high: `${who}'s access looks suspicious — review when you can.`,
|
||||
block: `${who}'s access was blocked.`,
|
||||
}
|
||||
return {
|
||||
type: 'fraud.scored',
|
||||
ts: a.ts,
|
||||
text: friendlyText[band] || `${who}'s invitation was checked.`,
|
||||
band,
|
||||
}
|
||||
}
|
||||
|
||||
// Add-guest form
|
||||
const newGuest = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
|
||||
const addingGuest = ref(false)
|
||||
|
||||
async function addGuest() {
|
||||
addingGuest.value = true
|
||||
try {
|
||||
await useApi(`/events/${eventId}/guests`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: newGuest.name,
|
||||
email: newGuest.email || null,
|
||||
phone: newGuest.phone || null,
|
||||
plus_ones: Number(newGuest.plus_ones) || 0,
|
||||
},
|
||||
})
|
||||
Object.assign(newGuest, { name: '', email: '', phone: '', plus_ones: 0 })
|
||||
await refresh()
|
||||
} finally {
|
||||
addingGuest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Issue token
|
||||
const issued = ref<Record<string, IssuedToken>>({})
|
||||
const issuing = ref<string | null>(null)
|
||||
const copiedFor = ref<string | null>(null)
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function issueToken(guestId: string) {
|
||||
issuing.value = guestId
|
||||
try {
|
||||
const res = await useApi<IssuedToken>(`/events/${eventId}/guests/${guestId}/tokens`, {
|
||||
method: 'POST',
|
||||
})
|
||||
issued.value[guestId] = res
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
issuing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function rsvpUrl(token: string): string {
|
||||
if (import.meta.server) return ''
|
||||
return `${window.location.origin}/rsvp/${token}`
|
||||
}
|
||||
|
||||
async function copyLink(guestId: string, token: string) {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(rsvpUrl(token))
|
||||
copiedFor.value = guestId
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer)
|
||||
copyResetTimer = setTimeout(() => {
|
||||
if (copiedFor.value === guestId) copiedFor.value = null
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
console.error('clipboard copy failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Live monitor — RSVP + fraud feed via WS
|
||||
interface FeedItem {
|
||||
type: string
|
||||
ts: string
|
||||
text: string
|
||||
band?: string
|
||||
}
|
||||
const feed = ref<FeedItem[]>([])
|
||||
const wsConnected = ref(false)
|
||||
const guestById = computed(() => Object.fromEntries(guests.value.map((g) => [g.id, g])))
|
||||
|
||||
function pushFeed(item: FeedItem) {
|
||||
feed.value = [item, ...feed.value].slice(0, 50)
|
||||
}
|
||||
|
||||
let stopWS: (() => void) | null = null
|
||||
onMounted(() => {
|
||||
stopWS = useEventWS(eventId, (msg: WSMessage) => {
|
||||
wsConnected.value = true
|
||||
if (msg.type === 'rsvp.confirmed') {
|
||||
const g = guestById.value[msg.payload.guest_id]
|
||||
pushFeed({
|
||||
type: msg.type,
|
||||
ts: msg.timestamp,
|
||||
text: `${g?.name || 'Guest'} → ${msg.payload.response} (+${msg.payload.plus_ones || 0})`,
|
||||
})
|
||||
// Refresh stats and per-guest status so the counts reflect the new RSVP.
|
||||
refresh().catch(() => {})
|
||||
} else if (msg.type === 'fraud.scored') {
|
||||
const g = guestById.value[msg.payload.guest_id]
|
||||
const who = g?.name || 'A guest'
|
||||
const band = (msg.payload.risk as string) || 'low'
|
||||
// Plain-English text per band — no raw scores, no jargon.
|
||||
const friendlyText: Record<string, string> = {
|
||||
low: `${who} opened their invitation — looks normal.`,
|
||||
medium: `${who}'s access looks a bit unusual — worth a glance.`,
|
||||
high: `${who}'s access looks suspicious — review when you can.`,
|
||||
block: `${who}'s access was blocked.`,
|
||||
}
|
||||
pushFeed({
|
||||
type: msg.type,
|
||||
ts: msg.timestamp,
|
||||
text: friendlyText[band] || `${who}'s invitation was checked.`,
|
||||
band,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => stopWS?.())
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
|
||||
}
|
||||
function fmtDate(iso: string): string {
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Friendly pill label for an access-check item (replaces "fraud · {band}").
|
||||
function checkLabel(band?: string): string {
|
||||
switch (band) {
|
||||
case 'low': return 'Verified'
|
||||
case 'medium': return 'Review'
|
||||
case 'high': return 'Suspicious'
|
||||
case 'block': return 'Blocked'
|
||||
default: return 'Checked'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="text-sm text-zinc-500">Loading…</section>
|
||||
<section v-else-if="!event" class="card">Event not found.</section>
|
||||
<section v-else class="space-y-8">
|
||||
<div>
|
||||
<NuxtLink to="/dashboard" class="mb-2 inline-block text-sm text-zinc-400 hover:text-zinc-200">
|
||||
← Back to dashboard
|
||||
</NuxtLink>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{{ event.name }}</h1>
|
||||
<span class="badge bg-zinc-800 text-zinc-300">{{ event.status }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400">{{ event.venue }} · {{ fmtDate(event.event_date) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Guests + add form -->
|
||||
<div class="space-y-6">
|
||||
<div class="card">
|
||||
<h2 class="mb-3 text-lg font-semibold">Add a guest</h2>
|
||||
<form class="grid grid-cols-1 gap-3 md:grid-cols-2" @submit.prevent="addGuest">
|
||||
<input v-model="newGuest.name" placeholder="Name" class="input md:col-span-2" required />
|
||||
<input v-model="newGuest.email" type="email" placeholder="Email" class="input" />
|
||||
<input v-model="newGuest.phone" placeholder="Phone" class="input" />
|
||||
<!-- Plus-ones stepper -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="label mb-1">Plus-ones allowed</label>
|
||||
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="newGuest.plus_ones <= 0"
|
||||
@click="newGuest.plus_ones = Math.max(0, newGuest.plus_ones - 1)"
|
||||
>−</button>
|
||||
<span class="flex-1 text-center font-semibold tabular-nums text-zinc-100">{{ newGuest.plus_ones }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||
@click="newGuest.plus_ones++"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary md:col-span-2" :disabled="addingGuest">
|
||||
{{ addingGuest ? 'Adding…' : 'Add guest' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Guests</h2>
|
||||
<button class="text-xs text-zinc-400 hover:text-zinc-200" @click="refresh">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<button
|
||||
v-for="bucket in (['all','attending','declined','maybe','pending'] as const)"
|
||||
:key="bucket"
|
||||
class="rounded-md border px-3 py-2 text-left text-xs transition"
|
||||
:class="filter === bucket
|
||||
? 'border-brand-500 bg-brand-950/40 text-brand-200'
|
||||
: 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:border-zinc-700'"
|
||||
@click="filter = bucket"
|
||||
>
|
||||
<div class="text-base font-semibold text-zinc-100">
|
||||
{{ bucket === 'all' ? stats.total : stats[bucket] }}
|
||||
</div>
|
||||
<div class="capitalize">{{ bucket }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="guests.length === 0" class="text-sm text-zinc-500">No guests yet.</div>
|
||||
<div v-else-if="filteredGuests.length === 0" class="text-sm text-zinc-500">
|
||||
No guests match this filter.
|
||||
</div>
|
||||
<ul v-else class="divide-y divide-zinc-800">
|
||||
<li v-for="g in filteredGuests" :key="g.id" class="py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-zinc-100">{{ g.name }}</span>
|
||||
<span v-if="g.rsvp_response === 'attending'" class="badge-low">attending</span>
|
||||
<span v-else-if="g.rsvp_response === 'declined'" class="badge bg-zinc-800 text-zinc-300">declined</span>
|
||||
<span v-else-if="g.rsvp_response === 'maybe'" class="badge-medium">maybe</span>
|
||||
<span v-else class="badge bg-zinc-800/60 text-zinc-500">no response</span>
|
||||
|
||||
<span
|
||||
v-if="g.rsvp_risk_score != null && g.rsvp_risk_score >= 60"
|
||||
class="badge-high"
|
||||
:title="`Risk score ${g.rsvp_risk_score}`"
|
||||
>flagged</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-zinc-500">
|
||||
{{ g.email || '—' }}
|
||||
<span v-if="g.rsvp_response">
|
||||
· bringing
|
||||
{{ g.rsvp_plus_ones ?? 0 }} of {{ g.plus_ones }} plus-ones
|
||||
</span>
|
||||
<span v-else>
|
||||
· invited +{{ g.plus_ones }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="issued[g.id]" class="mt-2 break-all rounded border border-zinc-800 bg-zinc-950 px-2 py-1 font-mono text-xs text-brand-300">
|
||||
{{ rsvpUrl(issued[g.id].token) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
v-if="!issued[g.id] && !g.rsvp_response"
|
||||
class="btn-ghost"
|
||||
:disabled="issuing === g.id || g.has_token"
|
||||
:title="g.has_token ? 'A token has already been issued for this guest' : ''"
|
||||
@click="issueToken(g.id)"
|
||||
>
|
||||
{{ issuing === g.id ? '…' : g.has_token ? 'Link issued' : 'Generate link' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="issued[g.id]"
|
||||
class="btn-ghost"
|
||||
:class="copiedFor === g.id ? 'text-brand-300' : ''"
|
||||
@click="copyLink(g.id, issued[g.id].token)"
|
||||
>
|
||||
{{ copiedFor === g.id ? 'Copied ✓' : 'Copy link' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live monitor -->
|
||||
<aside class="card flex max-h-[80vh] flex-col">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Live monitor</h2>
|
||||
<span
|
||||
class="badge"
|
||||
:class="wsConnected ? 'bg-brand-900/40 text-brand-300' : 'bg-zinc-800 text-zinc-400'"
|
||||
>
|
||||
<span class="mr-1 inline-block h-1.5 w-1.5 rounded-full" :class="wsConnected ? 'bg-brand-400' : 'bg-zinc-500'"></span>
|
||||
{{ wsConnected ? 'live' : 'connecting' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-3 text-xs text-zinc-500">
|
||||
Guest responses and security alerts, the moment they happen.
|
||||
</p>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="feed.length === 0" class="rounded-lg border border-zinc-800/50 bg-zinc-900/30 p-5 text-center">
|
||||
<p class="mb-1 text-sm font-medium text-zinc-400">All quiet for now</p>
|
||||
<p class="text-xs leading-relaxed text-zinc-600">
|
||||
Once guests start responding, their RSVPs and any<br />
|
||||
security alerts will appear here in real time.
|
||||
</p>
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, i) in feed"
|
||||
:key="`${item.type}-${item.ts}-${i}`"
|
||||
class="rounded border border-zinc-800 bg-zinc-950 p-3 text-sm"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="font-mono text-zinc-500">{{ fmtTime(item.ts) }}</span>
|
||||
<span
|
||||
v-if="item.type === 'fraud.scored'"
|
||||
:class="`badge-${item.band || 'low'}`"
|
||||
>{{ checkLabel(item.band) }}</span>
|
||||
<span v-else class="badge-low">RSVP</span>
|
||||
</div>
|
||||
<p class="text-zinc-200">{{ item.text }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
const { host } = useHost()
|
||||
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const venue = ref('')
|
||||
const eventDate = ref('')
|
||||
const maxCapacity = ref<number>(50)
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
if (!host.value) return
|
||||
error.value = null
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await useApi<{ id: string }>('/events', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
host_id: host.value.id,
|
||||
name: name.value,
|
||||
slug: slug.value,
|
||||
event_date: new Date(eventDate.value).toISOString(),
|
||||
venue: venue.value,
|
||||
max_capacity: maxCapacity.value,
|
||||
},
|
||||
})
|
||||
await navigateTo(`/dashboard/events/${created.id}`)
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.error || e?.message || 'Failed to create event'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function autoSlug() {
|
||||
if (!slug.value) {
|
||||
slug.value = name.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Pretty date for the live preview card
|
||||
const previewDate = computed(() => {
|
||||
if (!eventDate.value) return ''
|
||||
try {
|
||||
return new Date(eventDate.value).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// Sample mockup data — clearly NOT the user's real activity
|
||||
// =============================================================
|
||||
interface DemoActivity {
|
||||
id: number
|
||||
name: string
|
||||
initials: string
|
||||
action: string
|
||||
extra?: string | null
|
||||
tone: 'attending' | 'declined' | 'maybe'
|
||||
}
|
||||
|
||||
const demoPool: Omit<DemoActivity, 'id'>[] = [
|
||||
{ name: 'John Doe', initials: 'JD', action: 'just confirmed', extra: '+2 guests', tone: 'attending' },
|
||||
{ name: 'Jane Smith', initials: 'JS', action: 'is attending', extra: null, tone: 'attending' },
|
||||
{ name: 'Alex Brown', initials: 'AB', action: 'replied maybe', extra: '+1 guest', tone: 'maybe' },
|
||||
{ name: 'Maria Garcia', initials: 'MG', action: 'just confirmed', extra: '+3 guests', tone: 'attending' },
|
||||
{ name: 'Tom Wilson', initials: 'TW', action: 'declined', extra: null, tone: 'declined' },
|
||||
{ name: 'Emma Davis', initials: 'ED', action: 'is attending', extra: '+1 guest', tone: 'attending' },
|
||||
]
|
||||
|
||||
let demoCounter = 0
|
||||
function makeActivity(idx: number): DemoActivity {
|
||||
return { ...demoPool[idx % demoPool.length], id: ++demoCounter }
|
||||
}
|
||||
|
||||
// Three visible entries; newest pushed on top, oldest drops off the bottom.
|
||||
const visibleActivities = ref<DemoActivity[]>([
|
||||
makeActivity(0),
|
||||
makeActivity(1),
|
||||
makeActivity(2),
|
||||
])
|
||||
const sampleConfirmed = ref(18)
|
||||
|
||||
let actTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
let idx = 3
|
||||
actTimer = setInterval(() => {
|
||||
visibleActivities.value = [makeActivity(idx), ...visibleActivities.value.slice(0, 2)]
|
||||
idx++
|
||||
}, 2800)
|
||||
|
||||
// Gentle tick-up on the sample stat so it feels alive
|
||||
countTimer = setInterval(() => {
|
||||
sampleConfirmed.value = sampleConfirmed.value >= 24 ? 18 : sampleConfirmed.value + 1
|
||||
}, 4500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (actTimer) clearInterval(actTimer)
|
||||
if (countTimer) clearInterval(countTimer)
|
||||
})
|
||||
|
||||
const toneClass: Record<DemoActivity['tone'], string> = {
|
||||
attending: 'bg-brand-500/20 text-brand-300',
|
||||
declined: 'bg-zinc-700/50 text-zinc-300',
|
||||
maybe: 'bg-amber-500/20 text-amber-300',
|
||||
}
|
||||
|
||||
// Capacity for the sample stat falls back to the form value when set,
|
||||
// otherwise a friendly default — so the mockup feels connected to the form.
|
||||
const sampleCapacity = computed(() => maxCapacity.value || 50)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<NuxtLink to="/dashboard" class="mb-6 inline-block text-sm text-zinc-400 hover:text-zinc-200">
|
||||
← Back to dashboard
|
||||
</NuxtLink>
|
||||
|
||||
<div class="grid gap-12 lg:grid-cols-2 lg:items-start">
|
||||
<!-- Left: form -->
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-semibold">Create a new event</h1>
|
||||
|
||||
<div v-if="!host" class="card text-sm text-zinc-400">
|
||||
Please sign in first.
|
||||
<NuxtLink to="/dashboard" class="text-brand-400">Go to dashboard</NuxtLink>
|
||||
</div>
|
||||
|
||||
<form v-else class="card space-y-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="label">Event name</label>
|
||||
<input v-model="name" class="input" placeholder="e.g. Sarah & James Wedding" required @blur="autoSlug" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
Slug
|
||||
<span class="ml-1 font-normal normal-case text-zinc-500">(used in the URL)</span>
|
||||
</label>
|
||||
<input v-model="slug" class="input" required pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="sarah-james-wedding" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Venue</label>
|
||||
<input v-model="venue" class="input" placeholder="e.g. The Grand Ballroom" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Date & time</label>
|
||||
<input v-model="eventDate" type="datetime-local" class="input" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
Max capacity
|
||||
<span class="ml-1 font-normal normal-case text-zinc-500">(guests)</span>
|
||||
</label>
|
||||
<input v-model.number="maxCapacity" type="number" min="1" class="input" />
|
||||
</div>
|
||||
|
||||
<button class="btn-primary w-full" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create event →' }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- =================== RIGHT: SAMPLE PREVIEW =================== -->
|
||||
<div class="hidden lg:block">
|
||||
<!-- Clear preview header with sample disclaimer -->
|
||||
<div class="mb-6 flex items-center justify-between gap-3">
|
||||
<p class="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-brand-500">
|
||||
<span class="h-px w-6 bg-brand-500"></span>
|
||||
Live preview
|
||||
</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full border border-amber-900/40 bg-amber-950/30 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-amber-300"
|
||||
title="Activity shown here is illustrative — your real dashboard will use actual guest responses"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Sample data
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stack of layered floating mockup cards -->
|
||||
<div class="relative mx-auto w-full max-w-sm py-6">
|
||||
<!-- Soft brand glow backdrop -->
|
||||
<div class="pointer-events-none absolute -inset-10 bg-gradient-to-br from-brand-500/20 via-transparent to-brand-500/10 blur-3xl"></div>
|
||||
|
||||
<!-- Floating sparkles -->
|
||||
<div class="pointer-events-none absolute -left-3 top-10 h-2 w-2 rounded-full bg-brand-400 opacity-70" style="animation: gg-ping 2.6s cubic-bezier(0,0,.2,1) infinite"></div>
|
||||
<div class="pointer-events-none absolute right-4 -top-2 h-1.5 w-1.5 rounded-full bg-brand-300 opacity-70" style="animation: gg-ping 3.2s cubic-bezier(0,0,.2,1) infinite; animation-delay: .6s"></div>
|
||||
<div class="pointer-events-none absolute -right-2 bottom-24 h-2 w-2 rounded-full bg-brand-500 opacity-60" style="animation: gg-ping 3.6s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.2s"></div>
|
||||
<div class="pointer-events-none absolute left-6 -bottom-2 h-1.5 w-1.5 rounded-full bg-brand-400 opacity-60" style="animation: gg-ping 2.8s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.8s"></div>
|
||||
|
||||
<!-- 1. Sample stats card (top-right, sample badge attached) -->
|
||||
<div
|
||||
class="absolute -right-2 -top-6 z-20 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur md:right-0"
|
||||
style="animation: gg-float-cw 5.5s ease-in-out infinite"
|
||||
>
|
||||
<p class="mb-1.5 flex items-center justify-between gap-3 text-[10px] font-medium uppercase tracking-wider">
|
||||
<span class="flex items-center gap-1.5 text-brand-400">
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
||||
</span>
|
||||
RSVPs
|
||||
</span>
|
||||
<span class="text-[9px] text-zinc-600">sample</span>
|
||||
</p>
|
||||
<div class="flex items-baseline gap-2 text-zinc-100">
|
||||
<span class="text-2xl font-bold tabular-nums transition-all duration-300">{{ sampleConfirmed }}</span>
|
||||
<span class="text-xs text-zinc-500">of {{ sampleCapacity }} confirmed</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-400 transition-all duration-700 ease-out"
|
||||
:style="{ width: `${Math.min(100, (sampleConfirmed / sampleCapacity) * 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. The user's actual invitation preview (centre, "Your event" badge) -->
|
||||
<div
|
||||
class="relative z-10 overflow-hidden rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900 via-zinc-900 to-zinc-950 shadow-2xl"
|
||||
style="animation: gg-float-ccw 6s ease-in-out infinite"
|
||||
>
|
||||
<div class="h-1 bg-gradient-to-r from-brand-600 via-brand-400 to-brand-600"></div>
|
||||
<div class="p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<p class="text-[10px] font-medium uppercase tracking-[0.22em] text-brand-400">
|
||||
✦ You're Invited
|
||||
</p>
|
||||
<span class="rounded-full border border-brand-800/60 bg-brand-950/40 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-brand-400">
|
||||
Your event
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mb-1 truncate text-lg font-semibold text-zinc-100">
|
||||
{{ name || 'Your event title' }}
|
||||
</h3>
|
||||
<p class="mb-4 text-xs text-zinc-500">
|
||||
<span :class="venue ? 'text-zinc-400' : ''">{{ venue || 'Venue' }}</span>
|
||||
·
|
||||
<span :class="previewDate ? 'text-zinc-400' : ''">{{ previewDate || 'Date' }}</span>
|
||||
</p>
|
||||
|
||||
<p class="mb-2 text-xs text-zinc-400">Will you be there?</p>
|
||||
<div class="flex gap-1.5">
|
||||
<span class="flex-1 rounded-md border border-brand-700/60 bg-brand-950/40 px-2 py-1.5 text-center text-xs font-medium text-brand-300">Attending</span>
|
||||
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Maybe</span>
|
||||
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Decline</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Recent activity list (below the invitation, slight tilt + float) -->
|
||||
<div
|
||||
class="relative z-20 mx-1 mt-4 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3.5 shadow-2xl backdrop-blur"
|
||||
style="animation: gg-float-cw-sm 5.4s ease-in-out infinite; animation-delay: .3s"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-zinc-300">
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-60"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
||||
</span>
|
||||
Recent activity
|
||||
</p>
|
||||
<span class="rounded-full border border-amber-900/40 bg-amber-950/30 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-amber-300">
|
||||
Sample
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
class="relative space-y-3"
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
enter-from-class="-translate-y-2 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="translate-y-2 opacity-0"
|
||||
>
|
||||
<li
|
||||
v-for="item in visibleActivities"
|
||||
:key="item.id"
|
||||
class="flex items-start gap-2.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
|
||||
:class="toneClass[item.tone]"
|
||||
>
|
||||
{{ item.initials }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-zinc-300">
|
||||
<span class="font-medium text-zinc-100">{{ item.name }}</span>
|
||||
<span class="text-zinc-500"> {{ item.action }}</span>
|
||||
<span v-if="item.extra" class="text-brand-400"> · {{ item.extra }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
interface EventSummary {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
event_date: string
|
||||
status: string
|
||||
venue: string
|
||||
max_capacity: number
|
||||
}
|
||||
|
||||
interface EventsResponse {
|
||||
events: EventSummary[]
|
||||
}
|
||||
|
||||
const { host, bootstrap } = useHost()
|
||||
|
||||
const email = ref('')
|
||||
const name = ref('')
|
||||
const bootstrapping = ref(false)
|
||||
const bootstrapError = ref<string | null>(null)
|
||||
|
||||
async function onBootstrap() {
|
||||
bootstrapError.value = null
|
||||
bootstrapping.value = true
|
||||
try {
|
||||
await bootstrap(email.value, name.value)
|
||||
} catch (e: any) {
|
||||
bootstrapError.value = e?.data?.error || e?.message || 'Failed to bootstrap'
|
||||
} finally {
|
||||
bootstrapping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const events = ref<EventSummary[]>([])
|
||||
const loadingEvents = ref(false)
|
||||
|
||||
async function loadEvents() {
|
||||
if (!host.value) return
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res = await useApi<EventsResponse>('/events', { query: { host_id: host.value.id } })
|
||||
events.value = res.events
|
||||
} finally {
|
||||
loadingEvents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(host, loadEvents, { immediate: true })
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<!--
|
||||
The dashboard is auth-gated by a localStorage-backed host. Rendering
|
||||
that conditional on the server (where there's no localStorage) and
|
||||
then again on the client (where there is) causes a hydration
|
||||
mismatch that leaves the layout stuck at the bootstrap card's width
|
||||
after a hard refresh. Skipping SSR for this block fixes both the
|
||||
flash and the layout shrink.
|
||||
-->
|
||||
<ClientOnly>
|
||||
<div v-if="!host" class="card max-w-md">
|
||||
<h1 class="mb-2 text-xl font-semibold">Get started</h1>
|
||||
<p class="mb-4 text-sm text-zinc-400">
|
||||
Demo bootstrap — enter an email + name to provision a host. We don't store passwords.
|
||||
</p>
|
||||
<label class="label">Email</label>
|
||||
<input v-model="email" type="email" class="input mb-3" placeholder="you@example.com" />
|
||||
<label class="label">Name</label>
|
||||
<input v-model="name" type="text" class="input mb-4" placeholder="Your name" />
|
||||
<button class="btn-primary w-full" :disabled="bootstrapping || !email || !name" @click="onBootstrap">
|
||||
{{ bootstrapping ? 'Setting up…' : 'Continue' }}
|
||||
</button>
|
||||
<p v-if="bootstrapError" class="mt-3 text-sm text-red-400">{{ bootstrapError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Your events</h1>
|
||||
<p class="text-sm text-zinc-400">Signed in as {{ host.name }} ({{ host.email }})</p>
|
||||
</div>
|
||||
<NuxtLink to="/dashboard/events/new" class="btn-primary">New event</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingEvents" class="text-sm text-zinc-500">Loading…</div>
|
||||
<div v-else-if="events.length === 0" class="card text-sm text-zinc-400">
|
||||
No events yet. Create one to get started.
|
||||
</div>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="ev in events"
|
||||
:key="ev.id"
|
||||
:to="`/dashboard/events/${ev.id}`"
|
||||
class="card transition hover:border-brand-700 hover:bg-zinc-900/80"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-zinc-50">{{ ev.name }}</h2>
|
||||
<span class="text-xs uppercase tracking-wide text-zinc-500">{{ ev.status }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400">{{ ev.venue || '—' }}</p>
|
||||
<p class="mt-2 text-xs text-zinc-500">{{ fmtDate(ev.event_date) }}</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #fallback>
|
||||
<div class="text-sm text-zinc-500">Loading dashboard…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,554 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: 'GuestGuard — Stress-free RSVPs for every occasion',
|
||||
description: 'Send personal invitations, track RSVPs in real time, and keep your guest list exactly as you planned it — weddings, parties, corporate events and more.',
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// Hero mockup: animated stat + cycling activity pill
|
||||
// =============================================================
|
||||
interface HeroActivity {
|
||||
id: number
|
||||
name: string
|
||||
initials: string
|
||||
action: string
|
||||
extra?: string | null
|
||||
tone: 'attending' | 'declined' | 'maybe'
|
||||
}
|
||||
|
||||
const heroPool: Omit<HeroActivity, 'id'>[] = [
|
||||
{ name: 'Aisha B.', initials: 'AB', action: 'just confirmed', extra: '+2 guests', tone: 'attending' },
|
||||
{ name: 'Marcus Chen', initials: 'MC', action: 'is attending', extra: null, tone: 'attending' },
|
||||
{ name: 'Sofia Rivera', initials: 'SR', action: 'replied maybe', extra: '+1 guest', tone: 'maybe' },
|
||||
{ name: 'John Doe', initials: 'JD', action: 'just confirmed', extra: '+3 guests', tone: 'attending' },
|
||||
{ name: 'Priya Sharma', initials: 'PS', action: 'is attending', extra: '+1 guest', tone: 'attending' },
|
||||
]
|
||||
|
||||
let heroCounter = 0
|
||||
const heroActivity = ref<HeroActivity>({ ...heroPool[0], id: ++heroCounter })
|
||||
const liveConfirmed = ref(42)
|
||||
|
||||
let heroActTimer: ReturnType<typeof setInterval> | null = null
|
||||
let heroCountTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
let idx = 1
|
||||
heroActTimer = setInterval(() => {
|
||||
heroActivity.value = { ...heroPool[idx % heroPool.length], id: ++heroCounter }
|
||||
idx++
|
||||
}, 3000)
|
||||
|
||||
// Slow ticking count-up — 42 → 47, then loops
|
||||
heroCountTimer = setInterval(() => {
|
||||
liveConfirmed.value = liveConfirmed.value >= 47 ? 42 : liveConfirmed.value + 1
|
||||
}, 4200)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (heroActTimer) clearInterval(heroActTimer)
|
||||
if (heroCountTimer) clearInterval(heroCountTimer)
|
||||
})
|
||||
|
||||
const heroToneClass: Record<HeroActivity['tone'], string> = {
|
||||
attending: 'bg-brand-500/20 text-brand-300',
|
||||
declined: 'bg-zinc-700/50 text-zinc-300',
|
||||
maybe: 'bg-amber-500/20 text-amber-300',
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// FAQ items
|
||||
// =============================================================
|
||||
const faqs = [
|
||||
{
|
||||
q: 'Do my guests need to create an account to RSVP?',
|
||||
a: 'No. They simply click the personal link you send them and respond — no sign-up, no password, no friction. It works on any phone or computer.',
|
||||
},
|
||||
{
|
||||
q: 'Is GuestGuard free to start?',
|
||||
a: 'Yes. You can plan your first event, send invitations, and collect RSVPs without paying a cent. No credit card required to get going.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if someone forwards their invitation to a friend?',
|
||||
a: "We notice. Each link is tied to the original guest, and our system quietly checks for unusual behaviour — like the link being used from a different device or location. You'll see the alert on your dashboard.",
|
||||
},
|
||||
{
|
||||
q: 'Can I import my guest list from a spreadsheet?',
|
||||
a: 'Absolutely. Drop in your CSV with names, emails, and phone numbers, and GuestGuard will handle the rest.',
|
||||
},
|
||||
{
|
||||
q: 'Will guests notice all the security checks happening behind the scenes?',
|
||||
a: 'Not at all. From their side it feels like a simple, beautiful RSVP page. The protection happens invisibly so the experience stays delightful.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<!-- ============================================================ -->
|
||||
<!-- 1. HERO -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="grid gap-12 py-12 md:py-16 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:gap-16 lg:py-20">
|
||||
<div>
|
||||
<p class="mb-5 inline-flex items-center gap-2 rounded-full border border-brand-900/60 bg-brand-950/40 px-3 py-1 text-xs font-medium text-brand-400">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
||||
For Weddings, Parties & Every Gathering in Between
|
||||
</p>
|
||||
<h1 class="mb-6 text-4xl font-semibold leading-tight tracking-tight text-zinc-50 md:text-5xl">
|
||||
Know Exactly Who's Coming<br />
|
||||
<span class="text-brand-500">Before the Big Day.</span>
|
||||
</h1>
|
||||
<p class="mb-8 max-w-xl text-lg leading-relaxed text-zinc-400">
|
||||
Send each guest a personal invitation link, watch RSVPs roll in on your live dashboard,
|
||||
and let GuestGuard quietly handle the rest — so you can focus on making your
|
||||
event unforgettable.
|
||||
</p>
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-3">
|
||||
<NuxtLink to="/dashboard" class="btn-primary px-6 py-2.5 text-base">
|
||||
Start Planning →
|
||||
</NuxtLink>
|
||||
<a href="#how-it-works" class="btn-ghost px-6 py-2.5 text-base">
|
||||
See How It Works
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust strip -->
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Free to start
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No credit card needed
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-3.5 w-3.5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Set up in 2 minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =================== HERO VISUAL MOCKUP =================== -->
|
||||
<div class="relative mx-auto w-full max-w-sm py-6 lg:max-w-md">
|
||||
<!-- Soft brand glow backdrop -->
|
||||
<div class="pointer-events-none absolute -inset-10 bg-gradient-to-br from-brand-500/25 via-transparent to-brand-500/10 blur-3xl"></div>
|
||||
|
||||
<!-- Floating sparkle dots -->
|
||||
<div class="pointer-events-none absolute -left-4 top-8 h-2 w-2 rounded-full bg-brand-400 opacity-70" style="animation: gg-ping 2.6s cubic-bezier(0,0,.2,1) infinite"></div>
|
||||
<div class="pointer-events-none absolute right-6 -top-2 h-1.5 w-1.5 rounded-full bg-brand-300 opacity-70" style="animation: gg-ping 3.2s cubic-bezier(0,0,.2,1) infinite; animation-delay: .6s"></div>
|
||||
<div class="pointer-events-none absolute -right-2 bottom-20 h-2 w-2 rounded-full bg-brand-500 opacity-60" style="animation: gg-ping 3.6s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.2s"></div>
|
||||
<div class="pointer-events-none absolute left-8 -bottom-2 h-1.5 w-1.5 rounded-full bg-brand-400 opacity-60" style="animation: gg-ping 2.8s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.8s"></div>
|
||||
|
||||
<!-- 1. Stats card (top-right, floating, tilts +3°) -->
|
||||
<div
|
||||
class="absolute -right-2 -top-6 z-20 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur md:right-0"
|
||||
style="animation: gg-float-cw 5.5s ease-in-out infinite"
|
||||
>
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-brand-400">
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
||||
</span>
|
||||
Live RSVPs
|
||||
</p>
|
||||
<div class="flex items-baseline gap-2 text-zinc-100">
|
||||
<span class="text-2xl font-bold tabular-nums transition-all duration-300">{{ liveConfirmed }}</span>
|
||||
<span class="text-xs text-zinc-500">of 60 confirmed</span>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-2 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-400 transition-all duration-700 ease-out"
|
||||
:style="{ width: `${(liveConfirmed / 60) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Main invitation card (centre, floating, tilts -2°) -->
|
||||
<div
|
||||
class="relative z-10 overflow-hidden rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900 via-zinc-900 to-zinc-950 shadow-2xl"
|
||||
style="animation: gg-float-ccw 6s ease-in-out infinite"
|
||||
>
|
||||
<div class="h-1 bg-gradient-to-r from-brand-600 via-brand-400 to-brand-600"></div>
|
||||
<div class="p-6">
|
||||
<p class="mb-2 text-[10px] font-medium uppercase tracking-[0.22em] text-brand-400">
|
||||
✦ You're Invited
|
||||
</p>
|
||||
<h3 class="mb-1 text-xl font-semibold text-zinc-100">Sarah & James</h3>
|
||||
<p class="mb-4 text-xs text-zinc-500">The Grand Ballroom · Sat, Jun 15</p>
|
||||
|
||||
<p class="mb-2 text-xs text-zinc-400">Will you be there?</p>
|
||||
<div class="flex gap-1.5">
|
||||
<span class="flex-1 rounded-md border border-brand-700/60 bg-brand-950/40 px-2 py-1.5 text-center text-xs font-medium text-brand-300">Attending</span>
|
||||
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Maybe</span>
|
||||
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Decline</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Activity pill (bottom-left, floating, tilts -3°) -->
|
||||
<div
|
||||
class="absolute -bottom-6 -left-2 z-20 min-w-[14rem] rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-2.5 shadow-2xl backdrop-blur md:left-2"
|
||||
style="animation: gg-float-ccw-sm 5s ease-in-out infinite; animation-delay: .4s"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition duration-500 ease-out"
|
||||
enter-from-class="-translate-y-2 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-300 ease-in absolute inset-x-4 inset-y-2.5"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-2 opacity-0"
|
||||
mode="out-in"
|
||||
>
|
||||
<div :key="heroActivity.id" class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
|
||||
:class="heroToneClass[heroActivity.tone]"
|
||||
>
|
||||
{{ heroActivity.initials }}
|
||||
</span>
|
||||
<div class="min-w-0 text-xs">
|
||||
<p class="truncate font-medium text-zinc-100">
|
||||
{{ heroActivity.name }}
|
||||
<span class="font-normal text-zinc-500">{{ heroActivity.action }}</span>
|
||||
</p>
|
||||
<p class="text-[10px] text-zinc-500">
|
||||
<span v-if="heroActivity.extra" class="text-brand-400">{{ heroActivity.extra }} · </span>
|
||||
a moment ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 4. Security toast (bottom-right, floating, tilts +2°) -->
|
||||
<div
|
||||
class="absolute -bottom-2 -right-3 z-20 rounded-xl border border-amber-900/40 bg-zinc-900/95 px-3.5 py-2 shadow-2xl backdrop-blur md:right-0"
|
||||
style="animation: gg-float-cw-sm 5.8s ease-in-out infinite; animation-delay: 1s"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/20 text-amber-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="text-xs">
|
||||
<p class="font-medium text-zinc-100">Forwarded link blocked</p>
|
||||
<p class="text-[10px] text-zinc-500">Suspicious activity flagged</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 2. HOW IT WORKS -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="how-it-works" class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">How It Works</p>
|
||||
<h2 class="mb-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
From Guest List to Confirmed Seats,<br />in Three Simple Steps.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="relative grid gap-6 md:grid-cols-3">
|
||||
<!-- Connector line (hidden on mobile) -->
|
||||
<div class="pointer-events-none absolute left-[16%] right-[16%] top-7 hidden h-px bg-gradient-to-r from-transparent via-zinc-800 to-transparent md:block"></div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
1
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Add Your Guest List</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Type names in, paste from a spreadsheet, or upload a CSV. We'll handle the rest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
2
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Send Personal Links</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Each guest gets their own private invitation — share it by WhatsApp, email,
|
||||
or however you like.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<div class="relative z-10 mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-brand-900/60 bg-gradient-to-br from-brand-950 to-zinc-900 text-lg font-bold text-brand-400 shadow-lg">
|
||||
3
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Watch RSVPs Roll In</h3>
|
||||
<p class="mx-auto max-w-xs text-sm leading-relaxed text-zinc-400">
|
||||
Confirmations appear on your dashboard as they happen. Final headcount,
|
||||
ready when you are.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 3. FEATURES -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Why GuestGuard</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Everything You Need, Nothing You Don't.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Personal Invitations</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Every guest gets their own private link — no public sign-up forms, no
|
||||
gate-crashing, and no way for a forwarded link to let the wrong person in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Live RSVP Dashboard</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Watch confirmations roll in the moment guests respond. See who's attending,
|
||||
who's declined, and who still hasn't replied — all filtered in one clean view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card group transition hover:border-brand-800/60">
|
||||
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-500/10 transition group-hover:bg-brand-500/20">
|
||||
<svg class="h-5 w-5 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-zinc-100">Built-in Protection</h3>
|
||||
<p class="text-sm leading-relaxed text-zinc-400">
|
||||
Our system quietly watches for anything unusual — like the same link being
|
||||
used from two different places — and flags it before it becomes your problem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 4. PERFECT FOR -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Perfect For</p>
|
||||
<h2 class="mb-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Any Gathering Where Your Guest List Matters.
|
||||
</h2>
|
||||
<p class="mx-auto max-w-2xl text-zinc-500">
|
||||
Whether you're hosting six or six hundred, GuestGuard fits the way you plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Weddings</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8a3 3 0 100-6 3 3 0 000 6zm0 0v4m-7 8h14M5 16a7 7 0 0114 0v4H5v-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Birthdays</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-3m-4 3v-7H9v7m4 0h-4m4 0h4M9 7h1m-1 4h1m4-4h1m-1 4h1" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Corporate</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Anniversaries</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Launches</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/40 p-4 text-center transition hover:border-brand-800/60 hover:bg-zinc-900">
|
||||
<div class="mx-auto mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-zinc-100">Private Dinners</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 5. TESTIMONIALS -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Loved by Hosts</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
A Little Peace of Mind Goes a Long Way.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"Planning my daughter's wedding was already overwhelming — GuestGuard made the RSVP
|
||||
part the easiest piece. I knew exactly who was coming and didn't have to chase a single soul."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">EC</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Emma Carter</p>
|
||||
<p class="text-xs text-zinc-500">Mother of the bride · London</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"We host quarterly partner events. Cutting no-shows by sending personal links
|
||||
instead of a public form has been huge for our catering budget."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">MC</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Marcus Chen</p>
|
||||
<p class="text-xs text-zinc-500">Corporate events lead · Singapore</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="card">
|
||||
<div class="mb-4 flex gap-0.5 text-brand-400">
|
||||
<svg v-for="n in 5" :key="n" class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 00-.364 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118L10 13.347l-3.367 2.446c-.784.57-1.838-.196-1.539-1.118l1.286-3.957a1 1 0 00-.364-1.118L2.65 9.154c-.784-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote class="mb-5 text-sm leading-relaxed text-zinc-300">
|
||||
"My birthday party went from a stressful headcount nightmare to something I was
|
||||
actually looking forward to. Everyone got a beautiful link, and that was it."
|
||||
</blockquote>
|
||||
<figcaption class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/20 text-sm font-semibold text-brand-300">PS</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-100">Priya Sharma</p>
|
||||
<p class="text-xs text-zinc-500">Birthday host · Toronto</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 6. FAQ -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="border-t border-zinc-900 py-16 md:py-20">
|
||||
<div class="mb-12 text-center">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-brand-500">Questions, Answered</p>
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
The Things Hosts Usually Ask Us.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-3">
|
||||
<details
|
||||
v-for="(item, i) in faqs"
|
||||
:key="i"
|
||||
class="group rounded-xl border border-zinc-800 bg-zinc-900/40 px-5 py-4 transition open:border-brand-900/40 open:bg-zinc-900"
|
||||
>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-4">
|
||||
<span class="font-medium text-zinc-100">{{ item.q }}</span>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-zinc-500 transition group-open:rotate-45 group-open:text-brand-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</summary>
|
||||
<p class="mt-3 text-sm leading-relaxed text-zinc-400">{{ item.a }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 7. FINAL CTA -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="relative my-16 overflow-hidden rounded-3xl border border-brand-900/40 bg-gradient-to-br from-brand-950/40 via-zinc-900 to-zinc-950 p-10 text-center md:p-16">
|
||||
<div class="pointer-events-none absolute -left-20 -top-20 h-72 w-72 rounded-full bg-brand-500/10 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-20 -right-20 h-72 w-72 rounded-full bg-brand-500/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative">
|
||||
<h2 class="mb-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
Ready to Host With Confidence?
|
||||
</h2>
|
||||
<p class="mx-auto mb-8 max-w-xl text-zinc-400">
|
||||
Your next event deserves a perfect guest list. Get started in two minutes —
|
||||
no credit card, no commitment.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NuxtLink to="/dashboard" class="btn-primary px-6 py-3 text-base">
|
||||
Start Planning Your Event →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="mt-6 text-xs text-zinc-500">
|
||||
Already have an account?
|
||||
<NuxtLink to="/dashboard" class="text-brand-400 hover:text-brand-300">Sign in here</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
interface AccessResponse {
|
||||
guest: { id: string; name: string; email?: string | null; plus_ones: number }
|
||||
event: { id: string; name: string; venue: string; event_date: string }
|
||||
token: { id: string; status: string; expires_at: string }
|
||||
access_log_id: string
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
rsvp?: { id: string; response: string; plus_ones: number; risk_score: number }
|
||||
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token as string
|
||||
|
||||
const loading = ref(true)
|
||||
const access = ref<AccessResponse | null>(null)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const response = ref<'attending' | 'declined' | 'maybe'>('attending')
|
||||
const plusOnes = ref(0)
|
||||
const dietary = ref('')
|
||||
|
||||
const submitting = ref(false)
|
||||
const result = ref<RSVPSubmitResponse | null>(null)
|
||||
const submitError = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
access.value = await useApi<AccessResponse>(`/access/${token}`)
|
||||
if (access.value) plusOnes.value = access.value.guest.plus_ones || 0
|
||||
} catch (e: any) {
|
||||
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
submitError.value = null
|
||||
try {
|
||||
const fp = useFingerprint()
|
||||
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
response: response.value,
|
||||
plus_ones: plusOnes.value,
|
||||
dietary_notes: dietary.value || null,
|
||||
fingerprint: fp,
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 403 from BLOCK band returns a JSON body; surface its decision too.
|
||||
if (e?.data?.fraud) {
|
||||
result.value = e.data
|
||||
} else {
|
||||
submitError.value = e?.data?.error || e?.message || 'Could not submit RSVP'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string) {
|
||||
if (!iso) return ''
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-xl py-8">
|
||||
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation…</div>
|
||||
|
||||
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
|
||||
<h1 class="mb-2 text-xl font-semibold text-red-200">Invitation unavailable</h1>
|
||||
<p class="text-sm text-red-300">{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="result?.blocked" class="card border-red-900/60 bg-red-950/30">
|
||||
<h1 class="mb-2 text-xl font-semibold text-red-200">This invitation cannot be used</h1>
|
||||
<p class="text-sm text-red-300">
|
||||
The host has been notified of a suspicious access attempt.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-red-400">
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="result?.rsvp" class="card border-brand-900/60 bg-brand-950/20">
|
||||
<h1 class="mb-2 text-xl font-semibold text-brand-200">You're confirmed</h1>
|
||||
<p class="text-sm text-brand-300">
|
||||
Response recorded as <strong>{{ result.rsvp.response }}</strong> with
|
||||
+{{ result.rsvp.plus_ones }} plus-ones.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-zinc-500">
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
<span v-if="!result.fraud.used"> · fallback</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="access" class="card">
|
||||
<p class="text-xs uppercase tracking-widest text-brand-500">Invitation</p>
|
||||
<h1 class="mb-1 text-2xl font-semibold">{{ access.event.name }}</h1>
|
||||
<p class="mb-6 text-sm text-zinc-400">
|
||||
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
|
||||
</p>
|
||||
|
||||
<p class="mb-6 text-sm">
|
||||
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
|
||||
please confirm your response below.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="label">Response</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in (['attending', 'declined', 'maybe'] as const)"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="btn-ghost flex-1 capitalize"
|
||||
:class="response === opt ? 'border border-brand-500 text-brand-300' : 'border border-zinc-800'"
|
||||
@click="response = opt"
|
||||
>{{ opt }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="access.guest.plus_ones > 0" class="mb-4">
|
||||
<label class="label">
|
||||
Plus-ones
|
||||
<span class="ml-1 font-normal normal-case text-zinc-500">
|
||||
(you may bring up to {{ access.guest.plus_ones }})
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="plusOnes <= 0"
|
||||
@click="plusOnes = Math.max(0, plusOnes - 1)"
|
||||
>−</button>
|
||||
<span class="flex-1 text-center text-base font-semibold tabular-nums text-zinc-100">{{ plusOnes }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="plusOnes >= access.guest.plus_ones"
|
||||
@click="plusOnes = Math.min(access.guest.plus_ones, plusOnes + 1)"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mb-4 text-xs text-zinc-500">
|
||||
This invitation is for one person only — no plus-ones for this one.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="label">Dietary notes (optional)</label>
|
||||
<input v-model="dietary" class="input" placeholder="e.g. vegetarian" />
|
||||
</div>
|
||||
|
||||
<button class="btn-primary w-full" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? 'Submitting…' : 'Submit RSVP' }}
|
||||
</button>
|
||||
|
||||
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
DEFAULT: '#22c55e',
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
module github.com/alchemistkay/guestguard
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
google.golang.org/grpc v1.81.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tokenPrefix = "tk_"
|
||||
|
||||
var ErrInvalidTokenFormat = errors.New("invalid token format")
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func NewGenerator() *Generator {
|
||||
return &Generator{}
|
||||
}
|
||||
|
||||
func (Generator) Generate() (raw, hash string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = tokenPrefix + base64.RawURLEncoding.EncodeToString(buf)
|
||||
hash = HashToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func HashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ValidateFormat(raw string) error {
|
||||
if !strings.HasPrefix(raw, tokenPrefix) {
|
||||
return ErrInvalidTokenFormat
|
||||
}
|
||||
if len(raw) < len(tokenPrefix)+20 {
|
||||
return ErrInvalidTokenFormat
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate_ProducesDistinctTokens(t *testing.T) {
|
||||
g := NewGenerator()
|
||||
seen := make(map[string]struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
raw, hash, err := g.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "tk_") {
|
||||
t.Errorf("expected tk_ prefix, got %q", raw)
|
||||
}
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("expected 64-char hex hash, got %d", len(hash))
|
||||
}
|
||||
if _, dup := seen[raw]; dup {
|
||||
t.Fatal("duplicate token generated")
|
||||
}
|
||||
seen[raw] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashToken_Stable(t *testing.T) {
|
||||
if HashToken("tk_abc") != HashToken("tk_abc") {
|
||||
t.Fatal("expected deterministic hash")
|
||||
}
|
||||
if HashToken("tk_abc") == HashToken("tk_xyz") {
|
||||
t.Fatal("expected distinct hashes for distinct inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormat(t *testing.T) {
|
||||
if err := ValidateFormat("tk_" + strings.Repeat("a", 40)); err != nil {
|
||||
t.Errorf("expected valid, got %v", err)
|
||||
}
|
||||
if err := ValidateFormat("not-a-token"); err == nil {
|
||||
t.Error("expected error for missing prefix")
|
||||
}
|
||||
if err := ValidateFormat("tk_short"); err == nil {
|
||||
t.Error("expected error for too-short token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Env string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
NATSURL string
|
||||
FraudGRPCAddr string
|
||||
FraudGRPCTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
TokenSecret string
|
||||
TokenTTL time.Duration
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Env: getenv("GG_ENV", "development"),
|
||||
HTTPAddr: getenv("GG_HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: getenv("GG_DATABASE_URL", "postgres://guestguard:guestguard@localhost:5432/guestguard?sslmode=disable"),
|
||||
NATSURL: getenv("GG_NATS_URL", "nats://localhost:4222"),
|
||||
FraudGRPCAddr: getenv("GG_FRAUD_GRPC_ADDR", "fraud-engine:9091"),
|
||||
FraudGRPCTimeout: getenvDuration("GG_FRAUD_GRPC_TIMEOUT", 250*time.Millisecond),
|
||||
ShutdownTimeout: getenvDuration("GG_SHUTDOWN_TIMEOUT", 15*time.Second),
|
||||
TokenSecret: os.Getenv("GG_TOKEN_SECRET"),
|
||||
TokenTTL: getenvDuration("GG_TOKEN_TTL", 30*24*time.Hour),
|
||||
}
|
||||
|
||||
if cfg.Env == "production" && cfg.TokenSecret == "" {
|
||||
return nil, fmt.Errorf("GG_TOKEN_SECRET is required in production")
|
||||
}
|
||||
if cfg.TokenSecret == "" {
|
||||
cfg.TokenSecret = "dev-only-insecure-secret-change-me"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvDuration(key string, fallback time.Duration) time.Duration {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(v); err == nil {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type EventStatus string
|
||||
|
||||
const (
|
||||
EventStatusDraft EventStatus = "draft"
|
||||
EventStatusPublished EventStatus = "published"
|
||||
EventStatusClosed EventStatus = "closed"
|
||||
EventStatusArchived EventStatus = "archived"
|
||||
)
|
||||
|
||||
func (s EventStatus) Valid() bool {
|
||||
switch s {
|
||||
case EventStatusDraft, EventStatusPublished, EventStatusClosed, EventStatusArchived:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
HostID uuid.UUID `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 EventStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrEventNotFound = errors.New("event not found")
|
||||
ErrSlugTaken = errors.New("slug already in use")
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventStatus_Valid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in EventStatus
|
||||
want bool
|
||||
}{
|
||||
{"draft", EventStatusDraft, true},
|
||||
{"published", EventStatusPublished, true},
|
||||
{"closed", EventStatusClosed, true},
|
||||
{"archived", EventStatusArchived, true},
|
||||
{"empty", "", false},
|
||||
{"unknown", "running", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.in.Valid(); got != tt.want {
|
||||
t.Errorf("Valid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Guest struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
EventID uuid.UUID `json:"event_id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
PlusOnes int `json:"plus_ones"`
|
||||
DietaryNotes *string `json:"dietary_notes,omitempty"`
|
||||
TableNumber *int `json:"table_number,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
var ErrGuestNotFound = errors.New("guest not found")
|
||||
@@ -0,0 +1,41 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RSVPResponse string
|
||||
|
||||
const (
|
||||
RSVPAttending RSVPResponse = "attending"
|
||||
RSVPDeclined RSVPResponse = "declined"
|
||||
RSVPMaybe RSVPResponse = "maybe"
|
||||
)
|
||||
|
||||
func (r RSVPResponse) Valid() bool {
|
||||
switch r {
|
||||
case RSVPAttending, RSVPDeclined, RSVPMaybe:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type RSVP struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
Response RSVPResponse `json:"response"`
|
||||
PlusOnes int `json:"plus_ones"`
|
||||
DietaryNotes *string `json:"dietary_notes,omitempty"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
DeviceFingerprint map[string]any `json:"device_fingerprint,omitempty"`
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
RiskScore *int `json:"risk_score,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrRSVPAlreadySubmitted = errors.New("rsvp already submitted")
|
||||
ErrRSVPBlocked = errors.New("rsvp blocked due to fraud risk")
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TokenStatus string
|
||||
|
||||
const (
|
||||
TokenStatusActive TokenStatus = "active"
|
||||
TokenStatusUsed TokenStatus = "used"
|
||||
TokenStatusRevoked TokenStatus = "revoked"
|
||||
TokenStatusExpired TokenStatus = "expired"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
TokenHash string `json:"-"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Status TokenStatus `json:"status"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (t *Token) IsValid(now time.Time) error {
|
||||
switch t.Status {
|
||||
case TokenStatusUsed:
|
||||
return ErrTokenAlreadyUsed
|
||||
case TokenStatusRevoked:
|
||||
return ErrTokenRevoked
|
||||
case TokenStatusExpired:
|
||||
return ErrTokenExpired
|
||||
}
|
||||
if now.After(t.ExpiresAt) {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenRevoked = errors.New("token revoked")
|
||||
ErrTokenAlreadyUsed = errors.New("token already used")
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailTaken = errors.New("email already in use")
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
package fraud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "github.com/alchemistkay/guestguard/internal/fraudpb"
|
||||
)
|
||||
|
||||
type Decision struct {
|
||||
Score int `json:"score"`
|
||||
Risk string `json:"risk"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Used bool `json:"used"` // false means we returned the fallback (engine unavailable / timed out)
|
||||
}
|
||||
|
||||
type ScoreInput struct {
|
||||
EventID uuid.UUID
|
||||
GuestID uuid.UUID
|
||||
TokenID uuid.UUID
|
||||
AccessLogID uuid.UUID
|
||||
Fingerprint map[string]string
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
Referrer string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
stub pb.FraudServiceClient
|
||||
timeout time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, addr string, timeout time.Duration, logger *slog.Logger) (*Client, error) {
|
||||
if addr == "" {
|
||||
return nil, errors.New("fraud grpc addr is empty")
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial fraud grpc: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
stub: pb.NewFraudServiceClient(conn),
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// Score is a synchronous fraud check. If the engine is unreachable or slow,
|
||||
// it returns a permissive fallback (Used=false) so the API stays available.
|
||||
// The caller can still decide what to do with that signal.
|
||||
func (c *Client) Score(ctx context.Context, in ScoreInput) Decision {
|
||||
callCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.stub.Score(callCtx, &pb.ScoreRequest{
|
||||
EventId: in.EventID.String(),
|
||||
GuestId: in.GuestID.String(),
|
||||
TokenId: in.TokenID.String(),
|
||||
AccessLogId: in.AccessLogID.String(),
|
||||
Fingerprint: in.Fingerprint,
|
||||
IpAddress: in.IPAddress,
|
||||
UserAgent: in.UserAgent,
|
||||
Referrer: in.Referrer,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Warn("fraud sync score failed, falling back",
|
||||
"err", err,
|
||||
"code", status.Code(err),
|
||||
"guest_id", in.GuestID,
|
||||
)
|
||||
return Decision{Score: 0, Risk: "low", Reasons: []string{"fraud_engine_unavailable"}, Used: false}
|
||||
}
|
||||
|
||||
return Decision{
|
||||
Score: int(resp.Score),
|
||||
Risk: riskString(resp.Risk),
|
||||
Reasons: append([]string{}, resp.Reasons...),
|
||||
Used: true,
|
||||
}
|
||||
}
|
||||
|
||||
func riskString(r pb.Risk) string {
|
||||
switch r {
|
||||
case pb.Risk_RISK_LOW:
|
||||
return "low"
|
||||
case pb.Risk_RISK_MEDIUM:
|
||||
return "medium"
|
||||
case pb.Risk_RISK_HIGH:
|
||||
return "high"
|
||||
case pb.Risk_RISK_BLOCK:
|
||||
return "block"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsBlock is a small helper so callers don't depend on the string contract.
|
||||
func IsBlock(d Decision) bool {
|
||||
return d.Risk == "block"
|
||||
}
|
||||
|
||||
// IsRetryableErr distinguishes transient gRPC errors. Currently unused but
|
||||
// kept for future retry middleware.
|
||||
func IsRetryableErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch status.Code(err) {
|
||||
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v6.31.1
|
||||
// source: fraud/v1/fraud.proto
|
||||
|
||||
package fraudpb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type Risk int32
|
||||
|
||||
const (
|
||||
Risk_RISK_UNSPECIFIED Risk = 0
|
||||
Risk_RISK_LOW Risk = 1
|
||||
Risk_RISK_MEDIUM Risk = 2
|
||||
Risk_RISK_HIGH Risk = 3
|
||||
Risk_RISK_BLOCK Risk = 4
|
||||
)
|
||||
|
||||
// Enum value maps for Risk.
|
||||
var (
|
||||
Risk_name = map[int32]string{
|
||||
0: "RISK_UNSPECIFIED",
|
||||
1: "RISK_LOW",
|
||||
2: "RISK_MEDIUM",
|
||||
3: "RISK_HIGH",
|
||||
4: "RISK_BLOCK",
|
||||
}
|
||||
Risk_value = map[string]int32{
|
||||
"RISK_UNSPECIFIED": 0,
|
||||
"RISK_LOW": 1,
|
||||
"RISK_MEDIUM": 2,
|
||||
"RISK_HIGH": 3,
|
||||
"RISK_BLOCK": 4,
|
||||
}
|
||||
)
|
||||
|
||||
func (x Risk) Enum() *Risk {
|
||||
p := new(Risk)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x Risk) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (Risk) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_fraud_v1_fraud_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (Risk) Type() protoreflect.EnumType {
|
||||
return &file_fraud_v1_fraud_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x Risk) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Risk.Descriptor instead.
|
||||
func (Risk) EnumDescriptor() ([]byte, []int) {
|
||||
return file_fraud_v1_fraud_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type ScoreRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||
GuestId string `protobuf:"bytes,2,opt,name=guest_id,json=guestId,proto3" json:"guest_id,omitempty"`
|
||||
TokenId string `protobuf:"bytes,3,opt,name=token_id,json=tokenId,proto3" json:"token_id,omitempty"`
|
||||
AccessLogId string `protobuf:"bytes,4,opt,name=access_log_id,json=accessLogId,proto3" json:"access_log_id,omitempty"`
|
||||
Fingerprint map[string]string `protobuf:"bytes,5,rep,name=fingerprint,proto3" json:"fingerprint,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
IpAddress string `protobuf:"bytes,6,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"`
|
||||
UserAgent string `protobuf:"bytes,7,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"`
|
||||
Referrer string `protobuf:"bytes,8,opt,name=referrer,proto3" json:"referrer,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) Reset() {
|
||||
*x = ScoreRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_fraud_v1_fraud_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ScoreRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ScoreRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_fraud_v1_fraud_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ScoreRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ScoreRequest) Descriptor() ([]byte, []int) {
|
||||
return file_fraud_v1_fraud_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetEventId() string {
|
||||
if x != nil {
|
||||
return x.EventId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetGuestId() string {
|
||||
if x != nil {
|
||||
return x.GuestId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetTokenId() string {
|
||||
if x != nil {
|
||||
return x.TokenId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetAccessLogId() string {
|
||||
if x != nil {
|
||||
return x.AccessLogId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetFingerprint() map[string]string {
|
||||
if x != nil {
|
||||
return x.Fingerprint
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetIpAddress() string {
|
||||
if x != nil {
|
||||
return x.IpAddress
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetUserAgent() string {
|
||||
if x != nil {
|
||||
return x.UserAgent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScoreRequest) GetReferrer() string {
|
||||
if x != nil {
|
||||
return x.Referrer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ScoreResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Score int32 `protobuf:"varint,1,opt,name=score,proto3" json:"score,omitempty"`
|
||||
Risk Risk `protobuf:"varint,2,opt,name=risk,proto3,enum=guestguard.fraud.v1.Risk" json:"risk,omitempty"`
|
||||
Reasons []string `protobuf:"bytes,3,rep,name=reasons,proto3" json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ScoreResponse) Reset() {
|
||||
*x = ScoreResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_fraud_v1_fraud_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ScoreResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ScoreResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ScoreResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_fraud_v1_fraud_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ScoreResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ScoreResponse) Descriptor() ([]byte, []int) {
|
||||
return file_fraud_v1_fraud_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ScoreResponse) GetScore() int32 {
|
||||
if x != nil {
|
||||
return x.Score
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ScoreResponse) GetRisk() Risk {
|
||||
if x != nil {
|
||||
return x.Risk
|
||||
}
|
||||
return Risk_RISK_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *ScoreResponse) GetReasons() []string {
|
||||
if x != nil {
|
||||
return x.Reasons
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_fraud_v1_fraud_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_fraud_v1_fraud_proto_rawDesc = []byte{
|
||||
0x0a, 0x14, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2f, 0x76, 0x31, 0x2f, 0x66, 0x72, 0x61, 0x75, 0x64,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x67, 0x75, 0x65, 0x73, 0x74, 0x67, 0x75, 0x61,
|
||||
0x72, 0x64, 0x2e, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2e, 0x76, 0x31, 0x22, 0xf3, 0x02, 0x0a, 0x0c,
|
||||
0x53, 0x63, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08,
|
||||
0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x75, 0x65, 0x73, 0x74,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x75, 0x65, 0x73, 0x74,
|
||||
0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x49, 0x64, 0x12, 0x22, 0x0a,
|
||||
0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x04,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x49,
|
||||
0x64, 0x12, 0x54, 0x0a, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74,
|
||||
0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x67, 0x75, 0x65, 0x73, 0x74, 0x67, 0x75,
|
||||
0x61, 0x72, 0x64, 0x2e, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x63, 0x6f,
|
||||
0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72,
|
||||
0x70, 0x72, 0x69, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0b, 0x66, 0x69, 0x6e, 0x67,
|
||||
0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x70, 0x5f, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x70, 0x41,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72,
|
||||
0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65,
|
||||
0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65,
|
||||
0x72, 0x1a, 0x3e, 0x0a, 0x10, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
|
||||
0x01, 0x22, 0x6e, 0x0a, 0x0d, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x05, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x72, 0x69, 0x73, 0x6b,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x67, 0x75, 0x65, 0x73, 0x74, 0x67, 0x75,
|
||||
0x61, 0x72, 0x64, 0x2e, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x69, 0x73,
|
||||
0x6b, 0x52, 0x04, 0x72, 0x69, 0x73, 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x72, 0x65, 0x61, 0x73, 0x6f,
|
||||
0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x73, 0x2a, 0x5a, 0x0a, 0x04, 0x52, 0x69, 0x73, 0x6b, 0x12, 0x14, 0x0a, 0x10, 0x52, 0x49, 0x53,
|
||||
0x4b, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
|
||||
0x0c, 0x0a, 0x08, 0x52, 0x49, 0x53, 0x4b, 0x5f, 0x4c, 0x4f, 0x57, 0x10, 0x01, 0x12, 0x0f, 0x0a,
|
||||
0x0b, 0x52, 0x49, 0x53, 0x4b, 0x5f, 0x4d, 0x45, 0x44, 0x49, 0x55, 0x4d, 0x10, 0x02, 0x12, 0x0d,
|
||||
0x0a, 0x09, 0x52, 0x49, 0x53, 0x4b, 0x5f, 0x48, 0x49, 0x47, 0x48, 0x10, 0x03, 0x12, 0x0e, 0x0a,
|
||||
0x0a, 0x52, 0x49, 0x53, 0x4b, 0x5f, 0x42, 0x4c, 0x4f, 0x43, 0x4b, 0x10, 0x04, 0x32, 0x5e, 0x0a,
|
||||
0x0c, 0x46, 0x72, 0x61, 0x75, 0x64, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4e, 0x0a,
|
||||
0x05, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x21, 0x2e, 0x67, 0x75, 0x65, 0x73, 0x74, 0x67, 0x75,
|
||||
0x61, 0x72, 0x64, 0x2e, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x63, 0x6f,
|
||||
0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x67, 0x75, 0x65, 0x73,
|
||||
0x74, 0x67, 0x75, 0x61, 0x72, 0x64, 0x2e, 0x66, 0x72, 0x61, 0x75, 0x64, 0x2e, 0x76, 0x31, 0x2e,
|
||||
0x53, 0x63, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3d, 0x5a,
|
||||
0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x6c, 0x63, 0x68,
|
||||
0x65, 0x6d, 0x69, 0x73, 0x74, 0x6b, 0x61, 0x79, 0x2f, 0x67, 0x75, 0x65, 0x73, 0x74, 0x67, 0x75,
|
||||
0x61, 0x72, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x66, 0x72, 0x61,
|
||||
0x75, 0x64, 0x70, 0x62, 0x3b, 0x66, 0x72, 0x61, 0x75, 0x64, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_fraud_v1_fraud_proto_rawDescOnce sync.Once
|
||||
file_fraud_v1_fraud_proto_rawDescData = file_fraud_v1_fraud_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_fraud_v1_fraud_proto_rawDescGZIP() []byte {
|
||||
file_fraud_v1_fraud_proto_rawDescOnce.Do(func() {
|
||||
file_fraud_v1_fraud_proto_rawDescData = protoimpl.X.CompressGZIP(file_fraud_v1_fraud_proto_rawDescData)
|
||||
})
|
||||
return file_fraud_v1_fraud_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_fraud_v1_fraud_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_fraud_v1_fraud_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_fraud_v1_fraud_proto_goTypes = []any{
|
||||
(Risk)(0), // 0: guestguard.fraud.v1.Risk
|
||||
(*ScoreRequest)(nil), // 1: guestguard.fraud.v1.ScoreRequest
|
||||
(*ScoreResponse)(nil), // 2: guestguard.fraud.v1.ScoreResponse
|
||||
nil, // 3: guestguard.fraud.v1.ScoreRequest.FingerprintEntry
|
||||
}
|
||||
var file_fraud_v1_fraud_proto_depIdxs = []int32{
|
||||
3, // 0: guestguard.fraud.v1.ScoreRequest.fingerprint:type_name -> guestguard.fraud.v1.ScoreRequest.FingerprintEntry
|
||||
0, // 1: guestguard.fraud.v1.ScoreResponse.risk:type_name -> guestguard.fraud.v1.Risk
|
||||
1, // 2: guestguard.fraud.v1.FraudService.Score:input_type -> guestguard.fraud.v1.ScoreRequest
|
||||
2, // 3: guestguard.fraud.v1.FraudService.Score:output_type -> guestguard.fraud.v1.ScoreResponse
|
||||
3, // [3:4] is the sub-list for method output_type
|
||||
2, // [2:3] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_fraud_v1_fraud_proto_init() }
|
||||
func file_fraud_v1_fraud_proto_init() {
|
||||
if File_fraud_v1_fraud_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_fraud_v1_fraud_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ScoreRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_fraud_v1_fraud_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ScoreResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_fraud_v1_fraud_proto_rawDesc,
|
||||
NumEnums: 1,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_fraud_v1_fraud_proto_goTypes,
|
||||
DependencyIndexes: file_fraud_v1_fraud_proto_depIdxs,
|
||||
EnumInfos: file_fraud_v1_fraud_proto_enumTypes,
|
||||
MessageInfos: file_fraud_v1_fraud_proto_msgTypes,
|
||||
}.Build()
|
||||
File_fraud_v1_fraud_proto = out.File
|
||||
file_fraud_v1_fraud_proto_rawDesc = nil
|
||||
file_fraud_v1_fraud_proto_goTypes = nil
|
||||
file_fraud_v1_fraud_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v6.31.1
|
||||
// source: fraud/v1/fraud.proto
|
||||
|
||||
package fraudpb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
FraudService_Score_FullMethodName = "/guestguard.fraud.v1.FraudService/Score"
|
||||
)
|
||||
|
||||
// FraudServiceClient is the client API for FraudService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type FraudServiceClient interface {
|
||||
Score(ctx context.Context, in *ScoreRequest, opts ...grpc.CallOption) (*ScoreResponse, error)
|
||||
}
|
||||
|
||||
type fraudServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewFraudServiceClient(cc grpc.ClientConnInterface) FraudServiceClient {
|
||||
return &fraudServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *fraudServiceClient) Score(ctx context.Context, in *ScoreRequest, opts ...grpc.CallOption) (*ScoreResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ScoreResponse)
|
||||
err := c.cc.Invoke(ctx, FraudService_Score_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FraudServiceServer is the server API for FraudService service.
|
||||
// All implementations must embed UnimplementedFraudServiceServer
|
||||
// for forward compatibility.
|
||||
type FraudServiceServer interface {
|
||||
Score(context.Context, *ScoreRequest) (*ScoreResponse, error)
|
||||
mustEmbedUnimplementedFraudServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedFraudServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedFraudServiceServer struct{}
|
||||
|
||||
func (UnimplementedFraudServiceServer) Score(context.Context, *ScoreRequest) (*ScoreResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Score not implemented")
|
||||
}
|
||||
func (UnimplementedFraudServiceServer) mustEmbedUnimplementedFraudServiceServer() {}
|
||||
func (UnimplementedFraudServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeFraudServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to FraudServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeFraudServiceServer interface {
|
||||
mustEmbedUnimplementedFraudServiceServer()
|
||||
}
|
||||
|
||||
func RegisterFraudServiceServer(s grpc.ServiceRegistrar, srv FraudServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedFraudServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&FraudService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _FraudService_Score_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ScoreRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(FraudServiceServer).Score(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: FraudService_Score_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(FraudServiceServer).Score(ctx, req.(*ScoreRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// FraudService_ServiceDesc is the grpc.ServiceDesc for FraudService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var FraudService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "guestguard.fraud.v1.FraudService",
|
||||
HandlerType: (*FraudServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "Score",
|
||||
Handler: _FraudService_Score_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "fraud/v1/fraud.proto",
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package natspub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *nats.Conn
|
||||
js jetstream.JetStream
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, url string, logger *slog.Logger) (*Client, error) {
|
||||
conn, err := nats.Connect(url,
|
||||
nats.Name("guestguard-api"),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
|
||||
if err != nil {
|
||||
logger.Warn("nats disconnected", "err", err)
|
||||
}
|
||||
}),
|
||||
nats.ReconnectHandler(func(c *nats.Conn) {
|
||||
logger.Info("nats reconnected", "url", c.ConnectedUrl())
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect nats: %w", err)
|
||||
}
|
||||
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("jetstream: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{conn: conn, js: js, logger: logger}
|
||||
if err := c.ensureStream(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureStream(ctx context.Context) error {
|
||||
cfg := jetstream.StreamConfig{
|
||||
Name: StreamName,
|
||||
Subjects: StreamSubjects(),
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
Storage: jetstream.FileStorage,
|
||||
MaxAge: 14 * 24 * time.Hour,
|
||||
Replicas: 1,
|
||||
}
|
||||
|
||||
_, err := c.js.CreateOrUpdateStream(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create stream %s: %w", StreamName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
if c.conn != nil {
|
||||
c.conn.Drain() //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) JetStream() jetstream.JetStream {
|
||||
return c.js
|
||||
}
|
||||
|
||||
func (c *Client) PublishAccessAttempted(ctx context.Context, evt AccessAttempted) error {
|
||||
if evt.OccurredAt.IsZero() {
|
||||
evt.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
return c.publishJSON(ctx, SubjectAccessAttempted, evt, evt.GuestID)
|
||||
}
|
||||
|
||||
func (c *Client) PublishRSVPConfirmed(ctx context.Context, evt RSVPConfirmed) error {
|
||||
if evt.SubmittedAt.IsZero() {
|
||||
evt.SubmittedAt = time.Now().UTC()
|
||||
}
|
||||
return c.publishJSON(ctx, SubjectRSVPConfirmed, evt, evt.RSVPID)
|
||||
}
|
||||
|
||||
func (c *Client) publishJSON(ctx context.Context, subject string, payload any, dedupeKey uuid.UUID) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", subject, err)
|
||||
}
|
||||
|
||||
msg := &nats.Msg{Subject: subject, Data: body}
|
||||
msg.Header = nats.Header{}
|
||||
msg.Header.Set("Content-Type", "application/json")
|
||||
if dedupeKey != uuid.Nil {
|
||||
msg.Header.Set("Nats-Msg-Id", subject+":"+dedupeKey.String()+":"+time.Now().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
pubCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = c.js.PublishMsg(pubCtx, msg)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("publish %s timed out: %w", subject, err)
|
||||
}
|
||||
return fmt.Errorf("publish %s: %w", subject, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package natspub
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AccessAttempted struct {
|
||||
EventID uuid.UUID `json:"event_id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
TokenID uuid.UUID `json:"token_id"`
|
||||
AccessLogID uuid.UUID `json:"access_log_id"`
|
||||
Fingerprint map[string]any `json:"fingerprint,omitempty"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Referrer string `json:"referrer,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type FraudScored struct {
|
||||
EventID uuid.UUID `json:"event_id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
TokenID uuid.UUID `json:"token_id"`
|
||||
AccessLogID uuid.UUID `json:"access_log_id"`
|
||||
Score int `json:"score"`
|
||||
Risk string `json:"risk"`
|
||||
Reasons []string `json:"reasons"`
|
||||
ScoredAt time.Time `json:"scored_at"`
|
||||
}
|
||||
|
||||
type RSVPConfirmed struct {
|
||||
EventID uuid.UUID `json:"event_id"`
|
||||
GuestID uuid.UUID `json:"guest_id"`
|
||||
RSVPID uuid.UUID `json:"rsvp_id"`
|
||||
Response string `json:"response"`
|
||||
PlusOnes int `json:"plus_ones"`
|
||||
RiskScore *int `json:"risk_score,omitempty"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package natspub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
type RSVPConfirmedHandler func(ctx context.Context, evt RSVPConfirmed) error
|
||||
|
||||
type RSVPConfirmedSubscriber struct {
|
||||
logger *slog.Logger
|
||||
consumer jetstream.Consumer
|
||||
handler RSVPConfirmedHandler
|
||||
}
|
||||
|
||||
func NewRSVPConfirmedSubscriber(
|
||||
ctx context.Context,
|
||||
c *Client,
|
||||
durable string,
|
||||
handler RSVPConfirmedHandler,
|
||||
logger *slog.Logger,
|
||||
) (*RSVPConfirmedSubscriber, error) {
|
||||
cons, err := c.js.CreateOrUpdateConsumer(ctx, StreamName, jetstream.ConsumerConfig{
|
||||
Durable: durable,
|
||||
Name: durable,
|
||||
FilterSubject: SubjectRSVPConfirmed,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
MaxDeliver: 5,
|
||||
AckWait: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create consumer %s: %w", durable, err)
|
||||
}
|
||||
return &RSVPConfirmedSubscriber{logger: logger, consumer: cons, handler: handler}, nil
|
||||
}
|
||||
|
||||
func (s *RSVPConfirmedSubscriber) Start(ctx context.Context) (jetstream.ConsumeContext, error) {
|
||||
cc, err := s.consumer.Consume(func(msg jetstream.Msg) {
|
||||
var evt RSVPConfirmed
|
||||
if err := json.Unmarshal(msg.Data(), &evt); err != nil {
|
||||
s.logger.Error("decode rsvp.confirmed", "err", err)
|
||||
_ = msg.Term()
|
||||
return
|
||||
}
|
||||
hctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.handler(hctx, evt); err != nil {
|
||||
s.logger.Error("handle rsvp.confirmed", "err", err)
|
||||
_ = msg.NakWithDelay(2 * time.Second)
|
||||
return
|
||||
}
|
||||
_ = msg.Ack()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume: %w", err)
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package natspub
|
||||
|
||||
const (
|
||||
StreamName = "GUESTGUARD"
|
||||
|
||||
SubjectAccessAttempted = "guest.access.attempted"
|
||||
SubjectFraudScored = "fraud.scored"
|
||||
SubjectRSVPConfirmed = "rsvp.confirmed"
|
||||
SubjectInvitationSend = "invitation.send"
|
||||
)
|
||||
|
||||
func StreamSubjects() []string {
|
||||
return []string{
|
||||
"guest.>",
|
||||
"fraud.>",
|
||||
"rsvp.>",
|
||||
"invitation.>",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package natspub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
type FraudScoredHandler func(ctx context.Context, evt FraudScored) error
|
||||
|
||||
type FraudScoredSubscriber struct {
|
||||
logger *slog.Logger
|
||||
consumer jetstream.Consumer
|
||||
handler FraudScoredHandler
|
||||
}
|
||||
|
||||
func NewFraudScoredSubscriber(
|
||||
ctx context.Context,
|
||||
c *Client,
|
||||
durable string,
|
||||
handler FraudScoredHandler,
|
||||
logger *slog.Logger,
|
||||
) (*FraudScoredSubscriber, error) {
|
||||
cons, err := c.js.CreateOrUpdateConsumer(ctx, StreamName, jetstream.ConsumerConfig{
|
||||
Durable: durable,
|
||||
Name: durable,
|
||||
FilterSubject: SubjectFraudScored,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
MaxDeliver: 5,
|
||||
AckWait: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create consumer %s: %w", durable, err)
|
||||
}
|
||||
|
||||
return &FraudScoredSubscriber{
|
||||
logger: logger,
|
||||
consumer: cons,
|
||||
handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FraudScoredSubscriber) Start(ctx context.Context) (jetstream.ConsumeContext, error) {
|
||||
cc, err := s.consumer.Consume(func(msg jetstream.Msg) {
|
||||
var evt FraudScored
|
||||
if err := json.Unmarshal(msg.Data(), &evt); err != nil {
|
||||
s.logger.Error("decode fraud.scored", "err", err)
|
||||
_ = msg.Term()
|
||||
return
|
||||
}
|
||||
|
||||
hctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.handler(hctx, evt); err != nil {
|
||||
s.logger.Error("handle fraud.scored",
|
||||
"err", err, "guest_id", evt.GuestID, "score", evt.Score)
|
||||
_ = msg.NakWithDelay(2 * time.Second)
|
||||
return
|
||||
}
|
||||
_ = msg.Ack()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume: %w", err)
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelSMS Channel = "sms"
|
||||
ChannelEmail Channel = "email"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeInvitation Type = "invitation"
|
||||
TypeVerification Type = "verification"
|
||||
TypeConfirmation Type = "confirmation"
|
||||
TypeReminder Type = "reminder"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusSent Status = "sent"
|
||||
StatusDelivered Status = "delivered"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// Sender is the boundary between the worker and a real provider (Twilio,
|
||||
// SES, etc). Phase 3 ships a logging implementation; later phases swap it
|
||||
// out without touching consumer code.
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, msg OutboundMessage) (providerID string, err error)
|
||||
}
|
||||
|
||||
type OutboundMessage struct {
|
||||
GuestID uuid.UUID
|
||||
Channel Channel
|
||||
Type Type
|
||||
Subject string
|
||||
Body string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// Repo persists notification records.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepo(db *storage.DB) *Repo {
|
||||
return &Repo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type RecordParams struct {
|
||||
GuestID uuid.UUID
|
||||
Channel Channel
|
||||
Type Type
|
||||
Status Status
|
||||
ProviderID string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) {
|
||||
var providerID *string
|
||||
if p.ProviderID != "" {
|
||||
providerID = &p.ProviderID
|
||||
}
|
||||
var errStr *string
|
||||
if p.Error != "" {
|
||||
errStr = &p.Error
|
||||
}
|
||||
|
||||
var deliveredAt *time.Time
|
||||
if p.Status == StatusSent || p.Status == StatusDelivered {
|
||||
now := time.Now().UTC()
|
||||
deliveredAt = &now
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO notifications (guest_id, channel, type, status, provider_id,
|
||||
attempts, last_attempt, delivered_at, error)
|
||||
VALUES ($1, $2, $3, $4, $5, 1, now(), $6, $7)
|
||||
RETURNING id
|
||||
`
|
||||
var id uuid.UUID
|
||||
err := r.pool.QueryRow(ctx, q,
|
||||
p.GuestID, string(p.Channel), string(p.Type), string(p.Status),
|
||||
providerID, deliveredAt, errStr,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("record notification: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// LogSender pretends to send and just logs. Useful for Phase 3 demos and
|
||||
// tests; concrete providers (Twilio/SES) plug in later.
|
||||
type LogSender struct{}
|
||||
|
||||
func (LogSender) Send(_ context.Context, msg OutboundMessage) (string, error) {
|
||||
if msg.GuestID == uuid.Nil {
|
||||
return "", errors.New("missing guest id")
|
||||
}
|
||||
meta, _ := json.Marshal(msg.Metadata)
|
||||
providerID := "log:" + uuid.NewString()
|
||||
// We deliberately don't write to stdout here; the worker emits the slog
|
||||
// line so we control the structure.
|
||||
_ = meta
|
||||
return providerID, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type AccessLogRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAccessLogRepo(db *DB) *AccessLogRepo {
|
||||
return &AccessLogRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type CreateAccessLogParams struct {
|
||||
GuestID uuid.UUID
|
||||
TokenID uuid.UUID
|
||||
Fingerprint map[string]any
|
||||
IPAddress string
|
||||
}
|
||||
|
||||
func (r *AccessLogRepo) Create(ctx context.Context, p CreateAccessLogParams) (uuid.UUID, error) {
|
||||
var fpJSON []byte
|
||||
if p.Fingerprint != nil {
|
||||
b, err := json.Marshal(p.Fingerprint)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal fingerprint: %w", err)
|
||||
}
|
||||
fpJSON = b
|
||||
}
|
||||
|
||||
var ip *string
|
||||
if p.IPAddress != "" {
|
||||
ip = &p.IPAddress
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO access_logs (guest_id, token_id, fingerprint, ip_address)
|
||||
VALUES ($1, $2, $3, $4::inet)
|
||||
RETURNING id
|
||||
`
|
||||
var id uuid.UUID
|
||||
err := r.pool.QueryRow(ctx, q, p.GuestID, p.TokenID, fpJSON, ip).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
type ApplyScoreParams struct {
|
||||
AccessLogID uuid.UUID
|
||||
Score int
|
||||
Reasons []string
|
||||
Flagged bool
|
||||
}
|
||||
|
||||
// AccessCheckActivity is a scored access-log entry joined with the guest's
|
||||
// name. Used by the activity-history endpoint so dashboards can show
|
||||
// historical security checks (including blocked attempts) even when nobody
|
||||
// was watching the live monitor at the time.
|
||||
type AccessCheckActivity struct {
|
||||
GuestID uuid.UUID
|
||||
GuestName string
|
||||
Score int
|
||||
Reasons []string
|
||||
Flagged bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ListRecentScoredByEvent returns scored access-log entries for an event,
|
||||
// newest first. Unscored entries (someone opened the page but the fraud
|
||||
// engine hasn't replied yet) are excluded — they'd be noise on the feed.
|
||||
func (r *AccessLogRepo) ListRecentScoredByEvent(ctx context.Context, eventID uuid.UUID, limit int) ([]AccessCheckActivity, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
const q = `
|
||||
SELECT a.guest_id, g.name, a.risk_score, a.risk_reasons, a.flagged, a.created_at
|
||||
FROM access_logs a
|
||||
JOIN guests g ON g.id = a.guest_id
|
||||
WHERE g.event_id = $1 AND a.risk_score IS NOT NULL
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, q, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AccessCheckActivity
|
||||
for rows.Next() {
|
||||
var (
|
||||
a AccessCheckActivity
|
||||
reasons []string
|
||||
score int16
|
||||
)
|
||||
if err := rows.Scan(&a.GuestID, &a.GuestName, &score, &reasons, &a.Flagged, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Score = int(score)
|
||||
a.Reasons = reasons
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AccessLogRepo) ApplyScore(ctx context.Context, p ApplyScoreParams) error {
|
||||
const q = `
|
||||
UPDATE access_logs
|
||||
SET risk_score = $2, risk_reasons = $3, flagged = $4
|
||||
WHERE id = $1
|
||||
`
|
||||
tag, err := r.pool.Exec(ctx, q, p.AccessLogID, p.Score, p.Reasons, p.Flagged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("access_log not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
type EventRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewEventRepo(db *DB) *EventRepo {
|
||||
return &EventRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type CreateEventParams struct {
|
||||
HostID uuid.UUID
|
||||
Name string
|
||||
Slug string
|
||||
EventDate time.Time
|
||||
Venue string
|
||||
MaxCapacity int
|
||||
Settings map[string]any
|
||||
Status domain.EventStatus
|
||||
}
|
||||
|
||||
func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Event, error) {
|
||||
settings := p.Settings
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal settings: %w", err)
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||
`
|
||||
row := r.pool.QueryRow(ctx, q,
|
||||
p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
|
||||
)
|
||||
ev, err := scanEvent(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrSlugTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (r *EventRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Event, error) {
|
||||
const q = `
|
||||
SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||
FROM events WHERE id = $1
|
||||
`
|
||||
ev, err := scanEvent(r.pool.QueryRow(ctx, q, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrEventNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var (
|
||||
rows pgx.Rows
|
||||
err error
|
||||
)
|
||||
if hostID == uuid.Nil {
|
||||
rows, err = r.pool.Query(ctx, `
|
||||
SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||
FROM events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
} else {
|
||||
rows, err = r.pool.Query(ctx, `
|
||||
SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||
FROM events
|
||||
WHERE host_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, hostID, limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Event
|
||||
for rows.Next() {
|
||||
ev, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type UpdateEventParams struct {
|
||||
Name *string
|
||||
Slug *string
|
||||
EventDate *time.Time
|
||||
Venue *string
|
||||
MaxCapacity *int
|
||||
Settings *map[string]any
|
||||
Status *domain.EventStatus
|
||||
}
|
||||
|
||||
func (r *EventRepo) Update(ctx context.Context, id uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
|
||||
const q = `
|
||||
UPDATE events SET
|
||||
name = COALESCE($2, name),
|
||||
slug = COALESCE($3, slug),
|
||||
event_date = COALESCE($4, event_date),
|
||||
venue = COALESCE($5, venue),
|
||||
max_capacity = COALESCE($6, max_capacity),
|
||||
settings = COALESCE($7, settings),
|
||||
status = COALESCE($8, status),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
|
||||
`
|
||||
|
||||
var settingsJSON []byte
|
||||
if p.Settings != nil {
|
||||
b, err := json.Marshal(*p.Settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal settings: %w", err)
|
||||
}
|
||||
settingsJSON = b
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, id,
|
||||
p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
|
||||
)
|
||||
ev, err := scanEvent(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrEventNotFound
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrSlugTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (r *EventRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrEventNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanEvent(s rowScanner) (*domain.Event, error) {
|
||||
var (
|
||||
ev domain.Event
|
||||
settingsJSON []byte
|
||||
)
|
||||
err := s.Scan(
|
||||
&ev.ID, &ev.HostID, &ev.Name, &ev.Slug, &ev.EventDate, &ev.Venue,
|
||||
&ev.MaxCapacity, &settingsJSON, &ev.Status, &ev.CreatedAt, &ev.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(settingsJSON) > 0 {
|
||||
if err := json.Unmarshal(settingsJSON, &ev.Settings); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal settings: %w", err)
|
||||
}
|
||||
} else {
|
||||
ev.Settings = map[string]any{}
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
type GuestRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewGuestRepo(db *DB) *GuestRepo {
|
||||
return &GuestRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type CreateGuestParams struct {
|
||||
EventID uuid.UUID
|
||||
Name string
|
||||
Email *string
|
||||
Phone *string
|
||||
PlusOnes int
|
||||
DietaryNotes *string
|
||||
TableNumber *int
|
||||
}
|
||||
|
||||
func (r *GuestRepo) Create(ctx context.Context, p CreateGuestParams) (*domain.Guest, error) {
|
||||
const q = `
|
||||
INSERT INTO guests (event_id, name, email, phone, plus_ones, dietary_notes, table_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at
|
||||
`
|
||||
row := r.pool.QueryRow(ctx, q,
|
||||
p.EventID, p.Name, p.Email, p.Phone, p.PlusOnes, p.DietaryNotes, p.TableNumber,
|
||||
)
|
||||
return scanGuest(row)
|
||||
}
|
||||
|
||||
func (r *GuestRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Guest, error) {
|
||||
const q = `
|
||||
SELECT id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at
|
||||
FROM guests WHERE id = $1
|
||||
`
|
||||
g, err := scanGuest(r.pool.QueryRow(ctx, q, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrGuestNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *GuestRepo) ListByEvent(ctx context.Context, eventID uuid.UUID, limit, offset int) ([]*domain.Guest, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
const q = `
|
||||
SELECT id, event_id, name, email, phone, plus_ones, dietary_notes, table_number, created_at
|
||||
FROM guests
|
||||
WHERE event_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, q, eventID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Guest
|
||||
for rows.Next() {
|
||||
g, err := scanGuest(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanGuest(s rowScanner) (*domain.Guest, error) {
|
||||
var g domain.Guest
|
||||
err := s.Scan(
|
||||
&g.ID, &g.EventID, &g.Name, &g.Email, &g.Phone,
|
||||
&g.PlusOnes, &g.DietaryNotes, &g.TableNumber, &g.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// GuestWithRSVP is the dashboard view: a guest plus the RSVP submitted
|
||||
// against their token, if any. RSVP fields are nil when no response yet.
|
||||
type GuestWithRSVP struct {
|
||||
*domain.Guest
|
||||
RSVPResponse *string `json:"rsvp_response,omitempty"`
|
||||
RSVPPlusOnes *int `json:"rsvp_plus_ones,omitempty"`
|
||||
RSVPRiskScore *int `json:"rsvp_risk_score,omitempty"`
|
||||
RSVPSubmittedAt *time.Time `json:"rsvp_submitted_at,omitempty"`
|
||||
HasToken bool `json:"has_token"`
|
||||
}
|
||||
|
||||
func (r *GuestRepo) ListByEventWithRSVP(ctx context.Context, eventID uuid.UUID, limit, offset int) ([]*GuestWithRSVP, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
const q = `
|
||||
SELECT
|
||||
g.id, g.event_id, g.name, g.email, g.phone, g.plus_ones,
|
||||
g.dietary_notes, g.table_number, g.created_at,
|
||||
r.response, r.plus_ones, r.risk_score, r.submitted_at,
|
||||
t.id IS NOT NULL AS has_token
|
||||
FROM guests g
|
||||
LEFT JOIN rsvps r ON r.guest_id = g.id
|
||||
LEFT JOIN tokens t ON t.guest_id = g.id
|
||||
WHERE g.event_id = $1
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, q, eventID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*GuestWithRSVP
|
||||
for rows.Next() {
|
||||
var (
|
||||
g domain.Guest
|
||||
response *string
|
||||
rsvpPlusOnes *int
|
||||
riskScore *int
|
||||
submittedAt *time.Time
|
||||
hasToken bool
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.EventID, &g.Name, &g.Email, &g.Phone, &g.PlusOnes,
|
||||
&g.DietaryNotes, &g.TableNumber, &g.CreatedAt,
|
||||
&response, &rsvpPlusOnes, &riskScore, &submittedAt,
|
||||
&hasToken,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &GuestWithRSVP{
|
||||
Guest: &g,
|
||||
RSVPResponse: response,
|
||||
RSVPPlusOnes: rsvpPlusOnes,
|
||||
RSVPRiskScore: riskScore,
|
||||
RSVPSubmittedAt: submittedAt,
|
||||
HasToken: hasToken,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
DROP TABLE IF EXISTS access_logs;
|
||||
DROP TABLE IF EXISTS rsvps;
|
||||
DROP TABLE IF EXISTS tokens;
|
||||
DROP TABLE IF EXISTS guests;
|
||||
DROP TABLE IF EXISTS events;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
DROP TYPE IF EXISTS delivery_status;
|
||||
DROP TYPE IF EXISTS notification_type;
|
||||
DROP TYPE IF EXISTS notification_channel;
|
||||
DROP TYPE IF EXISTS rsvp_response;
|
||||
DROP TYPE IF EXISTS token_status;
|
||||
DROP TYPE IF EXISTS event_status;
|
||||
@@ -0,0 +1,122 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE event_status AS ENUM ('draft', 'published', 'closed', 'archived');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE token_status AS ENUM ('active', 'used', 'revoked', 'expired');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE rsvp_response AS ENUM ('attending', 'declined', 'maybe');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE notification_channel AS ENUM ('sms', 'email');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE notification_type AS ENUM ('invitation', 'verification', 'confirmation', 'reminder');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE delivery_status AS ENUM ('queued', 'sent', 'delivered', 'failed', 'bounced');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
host_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
event_date TIMESTAMPTZ NOT NULL,
|
||||
venue TEXT NOT NULL DEFAULT '',
|
||||
max_capacity INTEGER NOT NULL DEFAULT 0,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status event_status NOT NULL DEFAULT 'draft',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_host ON events(host_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(20),
|
||||
plus_ones INTEGER NOT NULL DEFAULT 0,
|
||||
dietary_notes TEXT,
|
||||
table_number INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_event ON guests(event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL UNIQUE REFERENCES guests(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
status token_status NOT NULL DEFAULT 'active',
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash) WHERE status = 'active';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL UNIQUE REFERENCES guests(id) ON DELETE CASCADE,
|
||||
response rsvp_response NOT NULL,
|
||||
plus_ones INTEGER NOT NULL DEFAULT 0,
|
||||
dietary_notes TEXT,
|
||||
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
device_fingerprint JSONB,
|
||||
ip_address INET,
|
||||
risk_score SMALLINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvps_guest ON rsvps(guest_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
|
||||
token_id UUID REFERENCES tokens(id) ON DELETE SET NULL,
|
||||
fingerprint JSONB,
|
||||
ip_address INET,
|
||||
geo_location JSONB,
|
||||
risk_score SMALLINT,
|
||||
risk_reasons TEXT[],
|
||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_logs_guest ON access_logs(guest_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_logs_flagged ON access_logs(flagged) WHERE flagged = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
|
||||
channel notification_channel NOT NULL,
|
||||
type notification_type NOT NULL,
|
||||
status delivery_status NOT NULL DEFAULT 'queued',
|
||||
provider_id VARCHAR(100),
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_attempt TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications(status) WHERE status IN ('queued', 'failed');
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_rsvps_submitted_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- This migration adjusts RSVP recording to align with synchronous fraud scoring.
|
||||
-- The base table already exists; this is a no-op placeholder so future schema
|
||||
-- changes have a slot. We add an index that helps the dashboard query rsvps
|
||||
-- joined with guests by event.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvps_submitted_at ON rsvps (submitted_at DESC);
|
||||
@@ -0,0 +1,115 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDB(ctx context.Context, dsn string) (*DB, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConns = 10
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect pool: %w", err)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
db.Pool.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Migrate(ctx context.Context) error {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
version string
|
||||
path string
|
||||
}
|
||||
var ups []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
version := strings.TrimSuffix(name, ".up.sql")
|
||||
ups = append(ups, migration{version: version, path: "migrations/" + name})
|
||||
}
|
||||
sort.Slice(ups, func(i, j int) bool { return ups[i].version < ups[j].version })
|
||||
|
||||
for _, m := range ups {
|
||||
var exists bool
|
||||
err := db.Pool.QueryRow(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)",
|
||||
m.version,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", m.version, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
sqlBytes, err := migrationsFS.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", m.path, err)
|
||||
}
|
||||
tx, err := db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", m.version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("apply %s: %w", m.version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES($1)", m.version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("record %s: %w", m.version, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit %s: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
type RSVPRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRSVPRepo(db *DB) *RSVPRepo {
|
||||
return &RSVPRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type CreateRSVPParams struct {
|
||||
GuestID uuid.UUID
|
||||
Response domain.RSVPResponse
|
||||
PlusOnes int
|
||||
DietaryNotes *string
|
||||
DeviceFingerprint map[string]any
|
||||
IPAddress string
|
||||
RiskScore *int
|
||||
}
|
||||
|
||||
func (r *RSVPRepo) Create(ctx context.Context, p CreateRSVPParams) (*domain.RSVP, error) {
|
||||
var fpJSON []byte
|
||||
if p.DeviceFingerprint != nil {
|
||||
b, err := json.Marshal(p.DeviceFingerprint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal fingerprint: %w", err)
|
||||
}
|
||||
fpJSON = b
|
||||
}
|
||||
|
||||
var ip *string
|
||||
if p.IPAddress != "" {
|
||||
ip = &p.IPAddress
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO rsvps (guest_id, response, plus_ones, dietary_notes,
|
||||
device_fingerprint, ip_address, risk_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::inet, $7)
|
||||
RETURNING id, guest_id, response, plus_ones, dietary_notes,
|
||||
submitted_at, device_fingerprint, ip_address::text, risk_score
|
||||
`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q,
|
||||
p.GuestID, p.Response, p.PlusOnes, p.DietaryNotes,
|
||||
fpJSON, ip, p.RiskScore,
|
||||
)
|
||||
|
||||
rs, err := scanRSVP(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrRSVPAlreadySubmitted
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// RSVPActivity is a denormalised RSVP entry for the activity feed —
|
||||
// includes the guest's name so the API can hand it to the frontend
|
||||
// without a separate lookup.
|
||||
type RSVPActivity struct {
|
||||
GuestID uuid.UUID
|
||||
GuestName string
|
||||
Response string
|
||||
PlusOnes int
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
// ListRecentByEvent returns the most recent RSVPs for an event, newest first.
|
||||
func (r *RSVPRepo) ListRecentByEvent(ctx context.Context, eventID uuid.UUID, limit int) ([]RSVPActivity, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
const q = `
|
||||
SELECT r.guest_id, g.name, r.response, r.plus_ones, r.submitted_at
|
||||
FROM rsvps r
|
||||
JOIN guests g ON g.id = r.guest_id
|
||||
WHERE g.event_id = $1
|
||||
ORDER BY r.submitted_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, q, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RSVPActivity
|
||||
for rows.Next() {
|
||||
var a RSVPActivity
|
||||
if err := rows.Scan(&a.GuestID, &a.GuestName, &a.Response, &a.PlusOnes, &a.SubmittedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanRSVP(s rowScanner) (*domain.RSVP, error) {
|
||||
var (
|
||||
rs domain.RSVP
|
||||
fpJSON []byte
|
||||
ip *string
|
||||
)
|
||||
err := s.Scan(
|
||||
&rs.ID, &rs.GuestID, &rs.Response, &rs.PlusOnes, &rs.DietaryNotes,
|
||||
&rs.SubmittedAt, &fpJSON, &ip, &rs.RiskScore,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fpJSON) > 0 {
|
||||
_ = json.Unmarshal(fpJSON, &rs.DeviceFingerprint)
|
||||
}
|
||||
if ip != nil {
|
||||
rs.IPAddress = ip
|
||||
}
|
||||
return &rs, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
type TokenRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTokenRepo(db *DB) *TokenRepo {
|
||||
return &TokenRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
type CreateTokenParams struct {
|
||||
GuestID uuid.UUID
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (r *TokenRepo) Create(ctx context.Context, p CreateTokenParams) (*domain.Token, error) {
|
||||
const q = `
|
||||
INSERT INTO tokens (guest_id, token_hash, expires_at, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
RETURNING id, guest_id, token_hash, expires_at, status, used_at, created_at
|
||||
`
|
||||
row := r.pool.QueryRow(ctx, q, p.GuestID, p.TokenHash, p.ExpiresAt)
|
||||
tk, err := scanToken(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, errors.New("guest already has a token")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return tk, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepo) GetByHash(ctx context.Context, hash string) (*domain.Token, error) {
|
||||
const q = `
|
||||
SELECT id, guest_id, token_hash, expires_at, status, used_at, created_at
|
||||
FROM tokens WHERE token_hash = $1
|
||||
`
|
||||
tk, err := scanToken(r.pool.QueryRow(ctx, q, hash))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrTokenNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return tk, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepo) MarkUsed(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE tokens SET status = 'used', used_at = now()
|
||||
WHERE id = $1 AND status = 'active'
|
||||
`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrTokenNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanToken(s rowScanner) (*domain.Token, error) {
|
||||
var tk domain.Token
|
||||
err := s.Scan(
|
||||
&tk.ID, &tk.GuestID, &tk.TokenHash, &tk.ExpiresAt,
|
||||
&tk.Status, &tk.UsedAt, &tk.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tk, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserRepo(db *DB) *UserRepo {
|
||||
return &UserRepo{pool: db.Pool}
|
||||
}
|
||||
|
||||
func (r *UserRepo) Create(ctx context.Context, email, name string) (*domain.User, error) {
|
||||
const q = `
|
||||
INSERT INTO users (email, name) VALUES ($1, $2)
|
||||
RETURNING id, email, name, created_at, updated_at
|
||||
`
|
||||
row := r.pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name))
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, domain.ErrEmailTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
const q = `SELECT id, email, name, created_at, updated_at FROM users WHERE email = $1`
|
||||
u, err := scanUser(r.pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email))))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func scanUser(s rowScanner) (*domain.User, error) {
|
||||
var u domain.User
|
||||
if err := s.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package guestguard.fraud.v1;
|
||||
|
||||
option go_package = "github.com/alchemistkay/guestguard/internal/fraudpb;fraudpb";
|
||||
|
||||
service FraudService {
|
||||
rpc Score(ScoreRequest) returns (ScoreResponse);
|
||||
}
|
||||
|
||||
message ScoreRequest {
|
||||
string event_id = 1;
|
||||
string guest_id = 2;
|
||||
string token_id = 3;
|
||||
string access_log_id = 4;
|
||||
map<string, string> fingerprint = 5;
|
||||
string ip_address = 6;
|
||||
string user_agent = 7;
|
||||
string referrer = 8;
|
||||
}
|
||||
|
||||
message ScoreResponse {
|
||||
int32 score = 1;
|
||||
Risk risk = 2;
|
||||
repeated string reasons = 3;
|
||||
}
|
||||
|
||||
enum Risk {
|
||||
RISK_UNSPECIFIED = 0;
|
||||
RISK_LOW = 1;
|
||||
RISK_MEDIUM = 2;
|
||||
RISK_HIGH = 3;
|
||||
RISK_BLOCK = 4;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//go:build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/api"
|
||||
"github.com/alchemistkay/guestguard/internal/fraud"
|
||||
pb "github.com/alchemistkay/guestguard/internal/fraudpb"
|
||||
"github.com/alchemistkay/guestguard/internal/natspub"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
// TestE2EHappyPath spins up real Postgres + NATS containers and an in-process
|
||||
// stub fraud gRPC server, then walks both the async (access → fraud.scored
|
||||
// → access_logs.flagged) and sync (RSVP submit) flows we manually verified
|
||||
// with `docker compose up`. This is the regression net for that walkthrough.
|
||||
func TestE2EHappyPath(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
dsn := startPostgres(t, ctx)
|
||||
natsURL := startNATS(t, ctx)
|
||||
|
||||
db, err := storage.NewDB(ctx, dsn)
|
||||
must(t, err, "connect db")
|
||||
t.Cleanup(db.Close)
|
||||
must(t, db.Migrate(ctx), "migrate")
|
||||
|
||||
natsClient, err := natspub.Connect(ctx, natsURL, logger)
|
||||
must(t, err, "connect nats")
|
||||
t.Cleanup(natsClient.Close)
|
||||
|
||||
stub := startStubFraudGRPC(t)
|
||||
fraudClient, err := fraud.Dial(ctx, stub.Addr, 2*time.Second, logger)
|
||||
must(t, err, "dial fraud")
|
||||
t.Cleanup(func() { _ = fraudClient.Close() })
|
||||
|
||||
accessLogs := storage.NewAccessLogRepo(db)
|
||||
sub, err := natspub.NewFraudScoredSubscriber(ctx, natsClient, "test-fraud-scored",
|
||||
func(ctx context.Context, evt natspub.FraudScored) error {
|
||||
return accessLogs.ApplyScore(ctx, storage.ApplyScoreParams{
|
||||
AccessLogID: evt.AccessLogID,
|
||||
Score: evt.Score,
|
||||
Reasons: evt.Reasons,
|
||||
Flagged: evt.Score >= 60,
|
||||
})
|
||||
}, logger)
|
||||
must(t, err, "create fraud subscriber")
|
||||
consumeCtx, err := sub.Start(ctx)
|
||||
must(t, err, "start fraud subscriber")
|
||||
t.Cleanup(consumeCtx.Stop)
|
||||
|
||||
rsvpCounter := subscribeRSVPConfirmed(t, ctx, natsClient)
|
||||
|
||||
srv := httptest.NewServer(api.NewServer(api.ServerDeps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
AccessPublisher: natsClient,
|
||||
RSVPPublisher: natsClient,
|
||||
FraudScorer: fraudClient,
|
||||
TokenTTL: 24 * time.Hour,
|
||||
}).Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
hostID := insertHost(t, ctx, db.Pool)
|
||||
|
||||
t.Run("async access flow flags access_logs", func(t *testing.T) {
|
||||
eventID := createEvent(t, srv.URL, hostID, "Async Test", "async-test")
|
||||
guestID := createGuest(t, srv.URL, eventID, "Async Guest")
|
||||
token := issueToken(t, srv.URL, eventID, guestID)
|
||||
|
||||
accessResp := getAccess(t, srv.URL, token)
|
||||
|
||||
stub.SetNext(72, "high", []string{"fingerprint differs from baseline"})
|
||||
|
||||
// Simulate the fraud-engine side of the pipeline: the engine consumes
|
||||
// access.attempted from NATS and publishes fraud.scored back. We do
|
||||
// the same publish directly so we don't need the Python service in the
|
||||
// test.
|
||||
mustPublishFraudScored(t, ctx, natsClient, natspub.FraudScored{
|
||||
EventID: eventID,
|
||||
GuestID: guestID,
|
||||
TokenID: accessResp.Token.ID,
|
||||
AccessLogID: accessResp.AccessLog,
|
||||
Score: 72,
|
||||
Risk: "high",
|
||||
Reasons: []string{"fingerprint differs from baseline"},
|
||||
ScoredAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
waitForFlagged(t, ctx, db.Pool, accessResp.AccessLog, 72, true)
|
||||
})
|
||||
|
||||
t.Run("sync rsvp flow records rsvp and marks token used", func(t *testing.T) {
|
||||
eventID := createEvent(t, srv.URL, hostID, "Sync Test", "sync-test")
|
||||
guestID := createGuest(t, srv.URL, eventID, "Sync Guest")
|
||||
token := issueToken(t, srv.URL, eventID, guestID)
|
||||
|
||||
stub.SetNext(15, "low", nil)
|
||||
|
||||
rsvpResp := submitRSVP(t, srv.URL, token, map[string]any{
|
||||
"response": "attending",
|
||||
"plus_ones": 0,
|
||||
})
|
||||
|
||||
if rsvpResp.Blocked {
|
||||
t.Fatalf("expected blocked=false, got %+v", rsvpResp)
|
||||
}
|
||||
if rsvpResp.Decision.Score != 15 || rsvpResp.Decision.Risk != "low" || !rsvpResp.Decision.Used {
|
||||
t.Fatalf("unexpected decision: %+v", rsvpResp.Decision)
|
||||
}
|
||||
if rsvpResp.RSVP == nil || rsvpResp.RSVP.RiskScore == nil || *rsvpResp.RSVP.RiskScore != 15 {
|
||||
t.Fatalf("rsvp missing risk_score=15: %+v", rsvpResp.RSVP)
|
||||
}
|
||||
|
||||
assertTokenUsed(t, ctx, db.Pool, guestID)
|
||||
waitForRSVPConfirmed(t, rsvpCounter, 1)
|
||||
})
|
||||
|
||||
t.Run("sync rsvp flow blocks when fraud score is BLOCK", func(t *testing.T) {
|
||||
eventID := createEvent(t, srv.URL, hostID, "Block Test", "block-test")
|
||||
guestID := createGuest(t, srv.URL, eventID, "Block Guest")
|
||||
token := issueToken(t, srv.URL, eventID, guestID)
|
||||
|
||||
stub.SetNext(95, "block", []string{"fingerprint differs from baseline", "ip address changed"})
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
srv.URL+"/rsvp/"+token,
|
||||
bytes.NewReader([]byte(`{"response":"attending","plus_ones":0}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
must(t, err, "POST /rsvp")
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 403 for BLOCK, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
assertNoRSVP(t, ctx, db.Pool, guestID)
|
||||
assertTokenStatus(t, ctx, db.Pool, guestID, "active")
|
||||
})
|
||||
}
|
||||
|
||||
// --- container helpers ---
|
||||
|
||||
func startPostgres(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
c, err := tcpostgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
tcpostgres.WithDatabase("guestguard"),
|
||||
tcpostgres.WithUsername("guestguard"),
|
||||
tcpostgres.WithPassword("guestguard"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(60*time.Second),
|
||||
),
|
||||
)
|
||||
must(t, err, "start postgres container")
|
||||
t.Cleanup(func() { _ = c.Terminate(context.Background()) })
|
||||
|
||||
dsn, err := c.ConnectionString(ctx, "sslmode=disable")
|
||||
must(t, err, "postgres connection string")
|
||||
return dsn
|
||||
}
|
||||
|
||||
func startNATS(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "nats:2.10-alpine",
|
||||
ExposedPorts: []string{"4222/tcp"},
|
||||
Cmd: []string{"-js"},
|
||||
WaitingFor: wait.ForLog("Server is ready").WithStartupTimeout(60 * time.Second),
|
||||
}
|
||||
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
must(t, err, "start nats container")
|
||||
t.Cleanup(func() { _ = c.Terminate(context.Background()) })
|
||||
|
||||
host, err := c.Host(ctx)
|
||||
must(t, err, "nats host")
|
||||
port, err := c.MappedPort(ctx, "4222/tcp")
|
||||
must(t, err, "nats port")
|
||||
return fmt.Sprintf("nats://%s:%s", host, port.Port())
|
||||
}
|
||||
|
||||
// --- stub fraud gRPC server ---
|
||||
|
||||
type stubFraud struct {
|
||||
pb.UnimplementedFraudServiceServer
|
||||
Addr string
|
||||
server *grpc.Server
|
||||
|
||||
score atomic.Int32
|
||||
risk atomic.Value // string
|
||||
reasons atomic.Value // []string
|
||||
}
|
||||
|
||||
func (s *stubFraud) Score(ctx context.Context, req *pb.ScoreRequest) (*pb.ScoreResponse, error) {
|
||||
risk := pb.Risk_RISK_LOW
|
||||
switch s.risk.Load().(string) {
|
||||
case "low":
|
||||
risk = pb.Risk_RISK_LOW
|
||||
case "medium":
|
||||
risk = pb.Risk_RISK_MEDIUM
|
||||
case "high":
|
||||
risk = pb.Risk_RISK_HIGH
|
||||
case "block":
|
||||
risk = pb.Risk_RISK_BLOCK
|
||||
}
|
||||
var reasons []string
|
||||
if r, _ := s.reasons.Load().([]string); r != nil {
|
||||
reasons = r
|
||||
}
|
||||
return &pb.ScoreResponse{
|
||||
Score: s.score.Load(),
|
||||
Risk: risk,
|
||||
Reasons: reasons,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubFraud) SetNext(score int, risk string, reasons []string) {
|
||||
s.score.Store(int32(score))
|
||||
s.risk.Store(risk)
|
||||
s.reasons.Store(reasons)
|
||||
}
|
||||
|
||||
func startStubFraudGRPC(t *testing.T) *stubFraud {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
must(t, err, "listen for stub fraud")
|
||||
|
||||
s := &stubFraud{Addr: lis.Addr().String()}
|
||||
s.risk.Store("low")
|
||||
s.reasons.Store([]string(nil))
|
||||
|
||||
s.server = grpc.NewServer()
|
||||
pb.RegisterFraudServiceServer(s.server, s)
|
||||
|
||||
go func() { _ = s.server.Serve(lis) }()
|
||||
t.Cleanup(s.server.Stop)
|
||||
return s
|
||||
}
|
||||
|
||||
// --- HTTP helpers ---
|
||||
|
||||
func createEvent(t *testing.T, base string, hostID uuid.UUID, name, slug string) uuid.UUID {
|
||||
t.Helper()
|
||||
body := map[string]any{
|
||||
"host_id": hostID.String(),
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"event_date": time.Now().Add(30 * 24 * time.Hour).UTC().Format(time.RFC3339),
|
||||
"venue": "Integration Hall",
|
||||
}
|
||||
var out struct{ ID uuid.UUID `json:"id"` }
|
||||
postJSON(t, base+"/events", body, http.StatusCreated, &out)
|
||||
return out.ID
|
||||
}
|
||||
|
||||
func createGuest(t *testing.T, base string, eventID uuid.UUID, name string) uuid.UUID {
|
||||
t.Helper()
|
||||
var out struct{ ID uuid.UUID `json:"id"` }
|
||||
postJSON(t, fmt.Sprintf("%s/events/%s/guests", base, eventID),
|
||||
map[string]any{"name": name}, http.StatusCreated, &out)
|
||||
return out.ID
|
||||
}
|
||||
|
||||
func issueToken(t *testing.T, base string, eventID, guestID uuid.UUID) string {
|
||||
t.Helper()
|
||||
var out struct{ Token string `json:"token"` }
|
||||
postJSON(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens", base, eventID, guestID),
|
||||
nil, http.StatusCreated, &out)
|
||||
return out.Token
|
||||
}
|
||||
|
||||
type accessResponse struct {
|
||||
Token *struct{ ID uuid.UUID `json:"id"` } `json:"token"`
|
||||
AccessLog uuid.UUID `json:"access_log_id"`
|
||||
}
|
||||
|
||||
func getAccess(t *testing.T, base, token string) accessResponse {
|
||||
t.Helper()
|
||||
resp, err := http.Get(base + "/access/" + token)
|
||||
must(t, err, "GET /access")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("GET /access status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var out accessResponse
|
||||
must(t, json.NewDecoder(resp.Body).Decode(&out), "decode access")
|
||||
return out
|
||||
}
|
||||
|
||||
type submitRSVPResponse struct {
|
||||
RSVP *struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
RiskScore *int `json:"risk_score"`
|
||||
} `json:"rsvp"`
|
||||
Decision fraud.Decision `json:"fraud"`
|
||||
Blocked bool `json:"blocked"`
|
||||
}
|
||||
|
||||
func submitRSVP(t *testing.T, base, token string, body map[string]any) submitRSVPResponse {
|
||||
t.Helper()
|
||||
var out submitRSVPResponse
|
||||
postJSON(t, base+"/rsvp/"+token, body, http.StatusCreated, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url string, body any, wantStatus int, out any) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, rdr)
|
||||
must(t, err, "build request "+url)
|
||||
if rdr != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
must(t, err, "do request "+url)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != wantStatus {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body)
|
||||
}
|
||||
if out != nil {
|
||||
must(t, json.NewDecoder(resp.Body).Decode(out), "decode response from "+url)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DB helpers ---
|
||||
|
||||
func insertHost(t *testing.T, ctx context.Context, pool *pgxpool.Pool) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`,
|
||||
fmt.Sprintf("test-%d@guestguard.test", time.Now().UnixNano()),
|
||||
"Integration Host",
|
||||
).Scan(&id)
|
||||
must(t, err, "insert host")
|
||||
return id
|
||||
}
|
||||
|
||||
func waitForFlagged(t *testing.T, ctx context.Context, pool *pgxpool.Pool, accessLogID uuid.UUID, wantScore int, wantFlagged bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var (
|
||||
score *int
|
||||
flagged bool
|
||||
)
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT risk_score, flagged FROM access_logs WHERE id = $1`,
|
||||
accessLogID,
|
||||
).Scan(&score, &flagged)
|
||||
if err == nil && score != nil && *score == wantScore && flagged == wantFlagged {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("access_log %s did not reach score=%d flagged=%v within 10s", accessLogID, wantScore, wantFlagged)
|
||||
}
|
||||
|
||||
func assertTokenUsed(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID) {
|
||||
t.Helper()
|
||||
var status string
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT status FROM tokens WHERE guest_id = $1`, guestID,
|
||||
).Scan(&status)
|
||||
must(t, err, "load token status")
|
||||
if status != "used" {
|
||||
t.Fatalf("expected token status=used for guest %s, got %s", guestID, status)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTokenStatus(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID, want string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT status FROM tokens WHERE guest_id = $1`, guestID,
|
||||
).Scan(&status)
|
||||
must(t, err, "load token status")
|
||||
if status != want {
|
||||
t.Fatalf("expected token status=%s for guest %s, got %s", want, guestID, status)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoRSVP(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID) {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM rsvps WHERE guest_id = $1`, guestID,
|
||||
).Scan(&n)
|
||||
must(t, err, "count rsvps")
|
||||
if n != 0 {
|
||||
t.Fatalf("expected 0 rsvps for blocked guest %s, got %d", guestID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NATS helpers ---
|
||||
|
||||
func mustPublishFraudScored(t *testing.T, ctx context.Context, c *natspub.Client, evt natspub.FraudScored) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(evt)
|
||||
_, err := c.JetStream().Publish(ctx, natspub.SubjectFraudScored, body)
|
||||
must(t, err, "publish fraud.scored")
|
||||
}
|
||||
|
||||
func subscribeRSVPConfirmed(t *testing.T, ctx context.Context, c *natspub.Client) *atomic.Int32 {
|
||||
t.Helper()
|
||||
cons, err := c.JetStream().CreateOrUpdateConsumer(ctx, natspub.StreamName, jetstream.ConsumerConfig{
|
||||
Durable: "test-rsvp-confirmed",
|
||||
Name: "test-rsvp-confirmed",
|
||||
FilterSubject: natspub.SubjectRSVPConfirmed,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
})
|
||||
must(t, err, "create rsvp consumer")
|
||||
|
||||
var counter atomic.Int32
|
||||
cc, err := cons.Consume(func(msg jetstream.Msg) {
|
||||
counter.Add(1)
|
||||
_ = msg.Ack()
|
||||
})
|
||||
must(t, err, "consume rsvp.confirmed")
|
||||
t.Cleanup(cc.Stop)
|
||||
return &counter
|
||||
}
|
||||
|
||||
func waitForRSVPConfirmed(t *testing.T, counter *atomic.Int32, want int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if counter.Load() >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected %d rsvp.confirmed events, saw %d", want, counter.Load())
|
||||
}
|
||||
|
||||
// --- misc ---
|
||||
|
||||
func must(t *testing.T, err error, op string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", op, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user