feat: build core API, fraud engine, notifier, and frontend
Phase 1 — Core API (Go): - Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5 - HMAC-signed per-guest tokens with format validation - Health endpoint with DB ping, slog JSON logging, graceful shutdown Phase 2 — NATS + Fraud Engine: - NATS JetStream pub/sub with explicit-ack consumers - Python/FastAPI fraud engine with heuristic risk scoring (fingerprint mismatch, IP change, missing signals, repeated access) - gRPC sync scoring with 250ms fail-open timeout - Per-guest baseline tracking; risk bands low/medium/high/block Phase 3 — Notifications + Frontend: - Notification worker scaffolding (Twilio/SES stubs, retry/backoff) - Nuxt 3 frontend with Tailwind dark theme + brand green - Live monitor via WebSocket with auto-reconnect - Activity history endpoint backfills monitor with RSVPs + scored access checks (including blocked attempts) UX polish: - Marketing-friendly landing page (hero mockup, how-it-works, features, use cases, testimonials, FAQ, final CTA) - Animated layered card mockups on landing + new-event page - Plus-ones stepper, RSVP status badges, filter buttons - Friendly access-check labels (Verified/Review/Suspicious/Blocked) - Dashboard hydration fix via ClientOnly wrapper Infrastructure: - docker-compose for full local dev (postgres, nats, api, fraud-engine, notifier, frontend) - Multi-stage Dockerfiles, non-root UID 1000 - Integration tests with testcontainers-go Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user