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
+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>