Files
guestguard/internal/storage/events.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

212 lines
5.2 KiB
Go

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
}