feat: build core API, fraud engine, notifier, and frontend

Phase 1 — Core API (Go):
- Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5
- HMAC-signed per-guest tokens with format validation
- Health endpoint with DB ping, slog JSON logging, graceful shutdown

Phase 2 — NATS + Fraud Engine:
- NATS JetStream pub/sub with explicit-ack consumers
- Python/FastAPI fraud engine with heuristic risk scoring
  (fingerprint mismatch, IP change, missing signals, repeated access)
- gRPC sync scoring with 250ms fail-open timeout
- Per-guest baseline tracking; risk bands low/medium/high/block

Phase 3 — Notifications + Frontend:
- Notification worker scaffolding (Twilio/SES stubs, retry/backoff)
- Nuxt 3 frontend with Tailwind dark theme + brand green
- Live monitor via WebSocket with auto-reconnect
- Activity history endpoint backfills monitor with RSVPs +
  scored access checks (including blocked attempts)

UX polish:
- Marketing-friendly landing page (hero mockup, how-it-works,
  features, use cases, testimonials, FAQ, final CTA)
- Animated layered card mockups on landing + new-event page
- Plus-ones stepper, RSVP status badges, filter buttons
- Friendly access-check labels (Verified/Review/Suspicious/Blocked)
- Dashboard hydration fix via ClientOnly wrapper

Infrastructure:
- docker-compose for full local dev (postgres, nats, api,
  fraud-engine, notifier, frontend)
- Multi-stage Dockerfiles, non-root UID 1000
- Integration tests with testcontainers-go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-11 21:08:56 +01:00
parent f760fc3e21
commit 3f8bc58ca9
89 changed files with 22729 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.claude
docs
frontend
fraud-engine
**/*.md
**/Dockerfile
docker-compose.yml
Makefile
+26
View File
@@ -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/
+55
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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}))
}
+26
View File
@@ -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"]
+209
View File
@@ -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
}
+106
View File
@@ -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:
+12
View File
@@ -0,0 +1,12 @@
__pycache__
*.pyc
*.pyo
.venv
.env
.env.local
tests
.pytest_cache
.ruff_cache
*.egg-info
build
dist
+29
View File
@@ -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"]
View File
+28
View File
@@ -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()
+74
View File
@@ -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)
+69
View File
@@ -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)
+80
View File
@@ -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()
+89
View File
@@ -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
+30
View File
@@ -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
+138
View File
@@ -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
View File
View File
+47
View File
@@ -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)
+58
View File
@@ -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: ...
+97
View File
@@ -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)
+43
View File
@@ -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"]
View File
+111
View File
@@ -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())
+81
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
node_modules
.nuxt
.output
.data
dist
.env
.env.*
.git
*.log
.DS_Store
+9
View File
@@ -0,0 +1,9 @@
node_modules
.nuxt
.output
.data
dist
.env
.env.*
*.log
.DS_Store
+32
View File
@@ -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"]
+57
View File
@@ -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>
+67
View File
@@ -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; }
}
+16
View File
@@ -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' },
})
}
+57
View File
@@ -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()
}
}
+22
View File
@@ -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,
}
}
+43
View File
@@ -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 }
}
+25
View File
@@ -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',
},
},
})
+14496
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+445
View File
@@ -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>
+326
View File
@@ -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 &amp; 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 &amp; 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>
+117
View File
@@ -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>
+554
View File
@@ -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 &amp; 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 &rarr;
</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 &amp; 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 &rarr;
</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>
+169
View File
@@ -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>
+35
View File
@@ -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',
],
},
},
},
}
+75
View File
@@ -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
)
+178
View File
@@ -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=
+126
View File
@@ -0,0 +1,126 @@
package api
import (
"errors"
"net/http"
"sort"
"time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// activityHandler serves the combined RSVP + access-check history for an
// event. The WebSocket hub only fans out *live* events to currently-
// connected dashboards; this endpoint is the catch-up channel for hosts
// who weren't watching when activity happened.
type activityHandler struct {
events *storage.EventRepo
rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo
}
type activityItem struct {
Type string `json:"type"` // "rsvp" | "access_check"
Timestamp time.Time `json:"ts"`
GuestID string `json:"guest_id"`
GuestName string `json:"guest_name"`
// RSVP-only
Response string `json:"response,omitempty"`
PlusOnes int `json:"plus_ones,omitempty"`
// Access-check-only
Score int `json:"score,omitempty"`
Band string `json:"band,omitempty"`
Blocked bool `json:"blocked,omitempty"`
}
// GET /events/{id}/activity?limit=50
//
// Returns the most recent N activity items (RSVPs + scored access checks)
// for an event, sorted newest first. Frontends use this on dashboard mount
// to backfill the live monitor with history.
func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.events.Get(r.Context(), eventID); err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
limit := atoiOr(r.URL.Query().Get("limit"), 50)
if limit <= 0 || limit > 200 {
limit = 50
}
// Pull from each source. We grab `limit` from each so that after
// merging we still have at least `limit` of the truly newest items.
rsvps, err := h.rsvps.ListRecentByEvent(r.Context(), eventID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load activity")
return
}
checks, err := h.accessLogs.ListRecentScoredByEvent(r.Context(), eventID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load activity")
return
}
items := make([]activityItem, 0, len(rsvps)+len(checks))
for _, a := range rsvps {
items = append(items, activityItem{
Type: "rsvp",
Timestamp: a.SubmittedAt,
GuestID: a.GuestID.String(),
GuestName: a.GuestName,
Response: a.Response,
PlusOnes: a.PlusOnes,
})
}
for _, c := range checks {
items = append(items, activityItem{
Type: "access_check",
Timestamp: c.CreatedAt,
GuestID: c.GuestID.String(),
GuestName: c.GuestName,
Score: c.Score,
Band: bandFromScore(c.Score),
Blocked: c.Score >= 80,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp.After(items[j].Timestamp)
})
if len(items) > limit {
items = items[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{
"activity": items,
})
}
// bandFromScore mirrors the friendly buckets used by the live WebSocket
// pipeline so backfilled items and live items render the same way in the
// dashboard feed. Thresholds match the fraud engine's intent: 029 looks
// normal, 3059 worth a glance, 6079 suspicious, ≥80 blocked.
func bandFromScore(score int) string {
switch {
case score >= 80:
return "block"
case score >= 60:
return "high"
case score >= 30:
return "medium"
default:
return "low"
}
}
+232
View File
@@ -0,0 +1,232 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type eventHandler struct {
repo *storage.EventRepo
}
type createEventRequest struct {
HostID string `json:"host_id"`
Name string `json:"name"`
Slug string `json:"slug"`
EventDate time.Time `json:"event_date"`
Venue string `json:"venue"`
MaxCapacity int `json:"max_capacity"`
Settings map[string]any `json:"settings"`
Status string `json:"status"`
}
var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
var req createEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if !slugRe.MatchString(req.Slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens")
return
}
if req.EventDate.IsZero() {
writeError(w, http.StatusBadRequest, "event_date is required")
return
}
hostID, err := uuid.Parse(req.HostID)
if err != nil {
writeError(w, http.StatusBadRequest, "host_id must be a valid uuid")
return
}
status := domain.EventStatus(req.Status)
if status == "" {
status = domain.EventStatusDraft
}
if !status.Valid() {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
ev, err := h.repo.Create(r.Context(), storage.CreateEventParams{
HostID: hostID,
Name: req.Name,
Slug: req.Slug,
EventDate: req.EventDate,
Venue: req.Venue,
MaxCapacity: req.MaxCapacity,
Settings: req.Settings,
Status: status,
})
if err != nil {
if errors.Is(err, domain.ErrSlugTaken) {
writeError(w, http.StatusConflict, "slug already in use")
return
}
writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
writeJSON(w, http.StatusCreated, ev)
}
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
ev, err := h.repo.Get(r.Context(), id)
if err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
writeJSON(w, http.StatusOK, ev)
}
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := atoiOr(q.Get("limit"), 50)
offset := atoiOr(q.Get("offset"), 0)
var hostID uuid.UUID
if v := q.Get("host_id"); v != "" {
parsed, err := uuid.Parse(v)
if err != nil {
writeError(w, http.StatusBadRequest, "host_id must be a valid uuid")
return
}
hostID = parsed
}
events, err := h.repo.List(r.Context(), hostID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*domain.Event{}
}
writeJSON(w, http.StatusOK, map[string]any{
"events": events,
"limit": limit,
"offset": offset,
})
}
type updateEventRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
EventDate *time.Time `json:"event_date"`
Venue *string `json:"venue"`
MaxCapacity *int `json:"max_capacity"`
Settings *map[string]any `json:"settings"`
Status *string `json:"status"`
}
func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
var req updateEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
params := storage.UpdateEventParams{
Name: req.Name,
EventDate: req.EventDate,
Venue: req.Venue,
MaxCapacity: req.MaxCapacity,
Settings: req.Settings,
}
if req.Slug != nil {
if !slugRe.MatchString(*req.Slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens")
return
}
params.Slug = req.Slug
}
if req.Status != nil {
s := domain.EventStatus(*req.Status)
if !s.Valid() {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
params.Status = &s
}
ev, err := h.repo.Update(r.Context(), id, params)
if err != nil {
switch {
case errors.Is(err, domain.ErrEventNotFound):
writeError(w, http.StatusNotFound, "event not found")
case errors.Is(err, domain.ErrSlugTaken):
writeError(w, http.StatusConflict, "slug already in use")
default:
writeError(w, http.StatusInternalServerError, "failed to update event")
}
return
}
writeJSON(w, http.StatusOK, ev)
}
func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseIDParam(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) {
raw := r.PathValue(name)
id, err := uuid.Parse(raw)
if err != nil {
writeError(w, http.StatusBadRequest, name+" must be a valid uuid")
return uuid.Nil, false
}
return id, true
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
if n, err := strconv.Atoi(s); err == nil {
return n
}
return fallback
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type guestHandler struct {
guests *storage.GuestRepo
events *storage.EventRepo
}
type createGuestRequest struct {
Name string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"`
TableNumber *int `json:"table_number"`
}
func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.events.Get(r.Context(), eventID); err != nil {
if errors.Is(err, domain.ErrEventNotFound) {
writeError(w, http.StatusNotFound, "event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
var req createGuestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return
}
g, err := h.guests.Create(r.Context(), storage.CreateGuestParams{
EventID: eventID,
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
TableNumber: req.TableNumber,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create guest")
return
}
writeJSON(w, http.StatusCreated, g)
}
func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
q := r.URL.Query()
limit := atoiOr(q.Get("limit"), 100)
offset := atoiOr(q.Get("offset"), 0)
guests, err := h.guests.ListByEventWithRSVP(r.Context(), eventID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list guests")
return
}
if guests == nil {
guests = []*storage.GuestWithRSVP{}
}
stats := struct {
Total int `json:"total"`
Attending int `json:"attending"`
Declined int `json:"declined"`
Maybe int `json:"maybe"`
Pending int `json:"pending"`
}{Total: len(guests)}
for _, g := range guests {
switch {
case g.RSVPResponse == nil:
stats.Pending++
case *g.RSVPResponse == string(domain.RSVPAttending):
stats.Attending++
case *g.RSVPResponse == string(domain.RSVPDeclined):
stats.Declined++
case *g.RSVPResponse == string(domain.RSVPMaybe):
stats.Maybe++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"guests": guests,
"stats": stats,
"limit": limit,
"offset": offset,
})
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type healthHandler struct {
pool *pgxpool.Pool
}
func (h *healthHandler) live(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *healthHandler) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.pool.Ping(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unavailable",
"db": "down",
})
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"db": "up",
})
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"bufio"
"errors"
"log/slog"
"net"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
// Hijack passes through to the underlying ResponseWriter so WebSocket
// upgrades work despite the middleware wrapper. Returning ErrNotSupported
// (rather than a custom error) lets callers detect this generically.
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := s.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("response writer does not support hijack")
}
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
}
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic", "err", rec, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
)
type errorBody struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if body == nil {
return
}
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Error("write json", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/fraud"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type rsvpPublisher interface {
PublishRSVPConfirmed(ctx context.Context, evt natspub.RSVPConfirmed) error
}
type fraudScorer interface {
Score(ctx context.Context, in fraud.ScoreInput) fraud.Decision
}
type rsvpHandler struct {
logger *slog.Logger
guests *storage.GuestRepo
tokens *storage.TokenRepo
events *storage.EventRepo
rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo
scorer fraudScorer
pub rsvpPublisher
}
type submitRSVPRequest struct {
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"`
Fingerprint map[string]any `json:"fingerprint"`
}
type submitRSVPResponse struct {
RSVP *domain.RSVP `json:"rsvp"`
Decision fraud.Decision `json:"fraud"`
Blocked bool `json:"blocked"`
}
// POST /rsvp/{token} — synchronous fraud check + RSVP recording.
func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
var req submitRSVPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
resp := domain.RSVPResponse(req.Response)
if !resp.Valid() {
writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
return
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
fingerprint := mergeFingerprint(req.Fingerprint, collectFingerprint(r))
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
decision := h.scorer.Score(r.Context(), fraud.ScoreInput{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: stringifyFingerprint(fingerprint),
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
})
if fraud.IsBlock(decision) {
writeJSON(w, http.StatusForbidden, submitRSVPResponse{
Decision: decision,
Blocked: true,
})
return
}
score := decision.Score
rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
GuestID: guest.ID,
Response: resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
writeError(w, http.StatusConflict, "rsvp already submitted for this guest")
return
}
h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to record rsvp")
return
}
if err := h.tokens.MarkUsed(r.Context(), tk.ID); err != nil {
h.logger.Warn("mark token used", "err", err, "token_id", tk.ID)
}
go func(evt natspub.RSVPConfirmed) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishRSVPConfirmed(ctx, evt); err != nil {
h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID)
}
}(natspub.RSVPConfirmed{
EventID: event.ID,
GuestID: guest.ID,
RSVPID: rsvp.ID,
Response: string(rsvp.Response),
PlusOnes: rsvp.PlusOnes,
RiskScore: &score,
SubmittedAt: rsvp.SubmittedAt,
})
writeJSON(w, http.StatusCreated, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
})
}
func mergeFingerprint(client map[string]any, server map[string]any) map[string]any {
out := make(map[string]any, len(server)+len(client))
for k, v := range server {
out[k] = v
}
for k, v := range client {
out["client_"+k] = v
}
return out
}
func stringifyFingerprint(fp map[string]any) map[string]string {
if fp == nil {
return nil
}
out := make(map[string]string, len(fp))
for k, v := range fp {
switch tv := v.(type) {
case string:
out[k] = tv
default:
b, _ := json.Marshal(tv)
out[k] = string(b)
}
}
return out
}
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/storage"
)
type Server struct {
logger *slog.Logger
db *storage.DB
hub *Hub
users *userHandler
events *eventHandler
guests *guestHandler
tokens *tokenHandler
rsvps *rsvpHandler
activity *activityHandler
ws *wsHandler
health *healthHandler
}
type ServerDeps struct {
Logger *slog.Logger
DB *storage.DB
Hub *Hub
AccessPublisher accessPublisher
RSVPPublisher rsvpPublisher
FraudScorer fraudScorer
TokenTTL time.Duration
}
func NewServer(deps ServerDeps) *Server {
eventRepo := storage.NewEventRepo(deps.DB)
guestRepo := storage.NewGuestRepo(deps.DB)
tokenRepo := storage.NewTokenRepo(deps.DB)
rsvpRepo := storage.NewRSVPRepo(deps.DB)
accessRepo := storage.NewAccessLogRepo(deps.DB)
userRepo := storage.NewUserRepo(deps.DB)
hub := deps.Hub
if hub == nil {
hub = NewHub(deps.Logger)
}
return &Server{
logger: deps.Logger,
db: deps.DB,
hub: hub,
users: &userHandler{repo: userRepo},
events: &eventHandler{repo: eventRepo},
guests: &guestHandler{guests: guestRepo, events: eventRepo},
tokens: &tokenHandler{
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
accessLogs: accessRepo,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
},
rsvps: &rsvpHandler{
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
rsvps: rsvpRepo,
accessLogs: accessRepo,
scorer: deps.FraudScorer,
pub: deps.RSVPPublisher,
},
activity: &activityHandler{
events: eventRepo,
rsvps: rsvpRepo,
accessLogs: accessRepo,
},
ws: &wsHandler{logger: deps.Logger, hub: hub},
health: &healthHandler{pool: deps.DB.Pool},
}
}
func (s *Server) Hub() *Hub { return s.hub }
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.health.live)
mux.HandleFunc("GET /health/ready", s.health.ready)
mux.HandleFunc("POST /users", s.users.upsert)
mux.HandleFunc("POST /events", s.events.create)
mux.HandleFunc("GET /events", s.events.list)
mux.HandleFunc("GET /events/{id}", s.events.get)
mux.HandleFunc("PATCH /events/{id}", s.events.update)
mux.HandleFunc("DELETE /events/{id}", s.events.delete)
mux.HandleFunc("POST /events/{id}/guests", s.guests.create)
mux.HandleFunc("GET /events/{id}/guests", s.guests.list)
mux.HandleFunc("GET /events/{id}/activity", s.activity.list)
mux.HandleFunc("POST /events/{id}/guests/{guest_id}/tokens", s.tokens.issue)
mux.HandleFunc("GET /access/{token}", s.tokens.access)
mux.HandleFunc("POST /rsvp/{token}", s.rsvps.submit)
mux.HandleFunc("GET /ws/events/{id}", s.ws.handle)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
})
var h http.Handler = mux
h = corsMiddleware(h)
h = loggingMiddleware(s.logger)(h)
h = recoverMiddleware(s.logger)(h)
return h
}
// Permissive CORS for the dev frontend on a different origin. In production
// the frontend is served from the same domain so this is largely a no-op.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Device-Fingerprint")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+197
View File
@@ -0,0 +1,197 @@
package api
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type accessPublisher interface {
PublishAccessAttempted(ctx context.Context, evt natspub.AccessAttempted) error
}
type tokenHandler struct {
logger *slog.Logger
guests *storage.GuestRepo
tokens *storage.TokenRepo
events *storage.EventRepo
accessLogs *storage.AccessLogRepo
gen *auth.Generator
ttl time.Duration
pub accessPublisher
}
type issueTokenResponse struct {
Token string `json:"token"`
TokenID uuid.UUID `json:"token_id"`
Meta *domain.Token `json:"meta"`
}
// POST /events/{id}/guests/{guest_id}/tokens — issue a token for the guest.
func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
guestID, ok := parseIDParam(w, r, "guest_id")
if !ok {
return
}
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrGuestNotFound) {
writeError(w, http.StatusNotFound, "guest not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if guest.EventID != eventID {
writeError(w, http.StatusNotFound, "guest not found in event")
return
}
raw, hash, err := h.gen.Generate()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{
GuestID: guestID,
TokenHash: hash,
ExpiresAt: time.Now().UTC().Add(h.ttl),
})
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusCreated, issueTokenResponse{
Token: raw,
TokenID: tk.ID,
Meta: tk,
})
}
type accessResponse struct {
Guest *domain.Guest `json:"guest"`
Event *domain.Event `json:"event"`
Token *domain.Token `json:"token"`
AccessLog uuid.UUID `json:"access_log_id"`
}
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
fingerprint := collectFingerprint(r)
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
go func(evt natspub.AccessAttempted) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil {
h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID)
}
}(natspub.AccessAttempted{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: fingerprint,
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
OccurredAt: time.Now().UTC(),
})
writeJSON(w, http.StatusOK, accessResponse{
Guest: guest,
Event: event,
Token: tk,
AccessLog: accessLogID,
})
}
func collectFingerprint(r *http.Request) map[string]any {
fp := map[string]any{
"user_agent": r.UserAgent(),
"accept_language": r.Header.Get("Accept-Language"),
"accept_encoding": r.Header.Get("Accept-Encoding"),
}
if v := r.Header.Get("Sec-CH-UA-Platform"); v != "" {
fp["platform"] = v
}
if v := r.Header.Get("X-Device-Fingerprint"); v != "" {
fp["client_fingerprint"] = v
}
return fp
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i > 0 {
host = host[:i]
}
return host
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/mail"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
type userHandler struct {
repo *storage.UserRepo
}
type upsertUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
// POST /users — idempotent: returns the existing user if the email already
// exists, creates one otherwise. This keeps the demo flow simple without
// requiring real auth.
func (h *userHandler) upsert(w http.ResponseWriter, r *http.Request) {
var req upsertUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, err := mail.ParseAddress(req.Email); err != nil {
writeError(w, http.StatusBadRequest, "email is invalid")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
u, err := h.repo.Create(r.Context(), req.Email, req.Name)
if err == nil {
writeJSON(w, http.StatusCreated, u)
return
}
if errors.Is(err, domain.ErrEmailTaken) {
existing, getErr := h.repo.GetByEmail(r.Context(), req.Email)
if getErr != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
writeJSON(w, http.StatusOK, existing)
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
}
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/google/uuid"
)
// WSEvent is the envelope pushed over WebSocket to dashboard clients.
type WSEvent struct {
Type string `json:"type"`
EventID uuid.UUID `json:"event_id"`
Payload json.RawMessage `json:"payload"`
Timestamp time.Time `json:"timestamp"`
}
type subscriber struct {
conn *websocket.Conn
send chan []byte
closed chan struct{}
}
// Hub fans out per-event WebSocket events to subscribers. Connections are
// keyed by event_id; a single dashboard page subscribes to one event at a
// time. Backpressure: if a slow client falls behind, we drop the message
// for that subscriber rather than block the broadcaster.
type Hub struct {
logger *slog.Logger
mu sync.RWMutex
subs map[uuid.UUID]map[*subscriber]struct{}
}
func NewHub(logger *slog.Logger) *Hub {
return &Hub{
logger: logger,
subs: make(map[uuid.UUID]map[*subscriber]struct{}),
}
}
// Broadcast publishes evt to all subscribers of evt.EventID.
func (h *Hub) Broadcast(evt WSEvent) {
if evt.Timestamp.IsZero() {
evt.Timestamp = time.Now().UTC()
}
body, err := json.Marshal(evt)
if err != nil {
h.logger.Error("ws marshal", "err", err)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for s := range h.subs[evt.EventID] {
select {
case s.send <- body:
default:
// drop on slow client; the connection will be closed when its
// reader goroutine notices the closed channel.
h.logger.Warn("ws subscriber slow, dropping message", "event_id", evt.EventID)
}
}
}
func (h *Hub) add(eventID uuid.UUID, s *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if h.subs[eventID] == nil {
h.subs[eventID] = make(map[*subscriber]struct{})
}
h.subs[eventID][s] = struct{}{}
}
func (h *Hub) remove(eventID uuid.UUID, s *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if subs, ok := h.subs[eventID]; ok {
delete(subs, s)
if len(subs) == 0 {
delete(h.subs, eventID)
}
}
}
type wsHandler struct {
logger *slog.Logger
hub *Hub
}
// GET /ws/events/{id} — dashboard live feed for one event.
func (h *wsHandler) handle(w http.ResponseWriter, r *http.Request) {
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// In dev the frontend runs on a different origin (localhost:3000 → localhost:8080).
// We're not relying on cookies, so it's safe to skip the same-origin check.
InsecureSkipVerify: true,
})
if err != nil {
h.logger.Warn("ws accept", "err", err)
return
}
sub := &subscriber{
conn: conn,
send: make(chan []byte, 32),
closed: make(chan struct{}),
}
h.hub.add(eventID, sub)
defer h.hub.remove(eventID, sub)
ctx := conn.CloseRead(r.Context())
pingTicker := time.NewTicker(20 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
conn.Close(websocket.StatusNormalClosure, "")
return
case msg := <-sub.send:
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
if err != nil {
conn.Close(websocket.StatusInternalError, "write failed")
return
}
case <-pingTicker.C:
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
return
}
}
}
}
+45
View File
@@ -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
}
+48
View File
@@ -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")
}
}
+64
View File
@@ -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
}
+44
View File
@@ -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")
)
+25
View File
@@ -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)
}
})
}
}
+22
View File
@@ -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")
+41
View File
@@ -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")
)
+49
View File
@@ -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")
)
+21
View File
@@ -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")
)
+136
View File
@@ -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
}
+381
View File
@@ -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
}
+121
View File
@@ -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",
}
+119
View File
@@ -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
}
+40
View File
@@ -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"`
}
+64
View File
@@ -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
}
+19
View File
@@ -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.>",
}
}
+72
View File
@@ -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
}
+122
View File
@@ -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
}
+127
View File
@@ -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
}
+211
View File
@@ -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
}
+168
View File
@@ -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);
+115
View File
@@ -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
}
+135
View File
@@ -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
}
+87
View File
@@ -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
}
+58
View File
@@ -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
}
+34
View File
@@ -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;
}
+487
View File
@@ -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)
}
}