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,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
|
||||
}
|
||||
Reference in New Issue
Block a user