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