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