Files
guestguard/internal/storage/guests.go
T
Kwaku Danso 3f8bc58ca9 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>
2026-05-11 21:08:56 +01:00

169 lines
4.3 KiB
Go

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()
}