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,445 @@
|
||||
<script setup lang="ts">
|
||||
interface Guest {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
plus_ones: number
|
||||
created_at: string
|
||||
rsvp_response?: 'attending' | 'declined' | 'maybe' | null
|
||||
rsvp_plus_ones?: number | null
|
||||
rsvp_risk_score?: number | null
|
||||
rsvp_submitted_at?: string | null
|
||||
has_token?: boolean
|
||||
}
|
||||
|
||||
interface GuestStats {
|
||||
total: number
|
||||
attending: number
|
||||
declined: number
|
||||
maybe: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
interface EventDetail {
|
||||
id: string
|
||||
host_id: string
|
||||
name: string
|
||||
slug: string
|
||||
event_date: string
|
||||
venue: string
|
||||
max_capacity: number
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface IssuedToken {
|
||||
token: string
|
||||
token_id: string
|
||||
}
|
||||
|
||||
interface WSMessage {
|
||||
type: string
|
||||
event_id: string
|
||||
payload: any
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const eventId = route.params.id as string
|
||||
|
||||
const event = ref<EventDetail | null>(null)
|
||||
const guests = ref<Guest[]>([])
|
||||
const stats = ref<GuestStats>({ total: 0, attending: 0, declined: 0, maybe: 0, pending: 0 })
|
||||
const filter = ref<'all' | 'attending' | 'declined' | 'maybe' | 'pending'>('all')
|
||||
const loading = ref(true)
|
||||
|
||||
async function refresh() {
|
||||
const [evt, list] = await Promise.all([
|
||||
useApi<EventDetail>(`/events/${eventId}`),
|
||||
useApi<{ guests: Guest[]; stats: GuestStats }>(`/events/${eventId}/guests`),
|
||||
])
|
||||
event.value = evt
|
||||
guests.value = list.guests || []
|
||||
if (list.stats) stats.value = list.stats
|
||||
}
|
||||
|
||||
const filteredGuests = computed(() => {
|
||||
if (filter.value === 'all') return guests.value
|
||||
if (filter.value === 'pending') return guests.value.filter((g) => !g.rsvp_response)
|
||||
return guests.value.filter((g) => g.rsvp_response === filter.value)
|
||||
})
|
||||
|
||||
interface ActivityItem {
|
||||
type: 'rsvp' | 'access_check'
|
||||
ts: string
|
||||
guest_id: string
|
||||
guest_name: string
|
||||
// RSVP
|
||||
response?: string
|
||||
plus_ones?: number
|
||||
// Access check
|
||||
score?: number
|
||||
band?: string
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refresh()
|
||||
// Pull history from the activity endpoint. The WebSocket hub only
|
||||
// broadcasts future events, so without this catch-up the monitor
|
||||
// is blank until a guest does something while the dashboard is open.
|
||||
backfillFeed()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function backfillFeed() {
|
||||
if (feed.value.length > 0) return // don't clobber WS items that arrived first
|
||||
try {
|
||||
const res = await useApi<{ activity: ActivityItem[] }>(
|
||||
`/events/${eventId}/activity?limit=50`,
|
||||
)
|
||||
feed.value = (res.activity || []).map(activityToFeedItem)
|
||||
} catch (e) {
|
||||
console.error('activity backfill failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function activityToFeedItem(a: ActivityItem): FeedItem {
|
||||
if (a.type === 'rsvp') {
|
||||
return {
|
||||
type: 'rsvp.confirmed',
|
||||
ts: a.ts,
|
||||
text: `${a.guest_name} → ${a.response} (+${a.plus_ones || 0})`,
|
||||
}
|
||||
}
|
||||
// access_check — friendly text per band, matching the live-WS handler.
|
||||
const band = a.band || 'low'
|
||||
const who = a.guest_name || 'A guest'
|
||||
const friendlyText: Record<string, string> = {
|
||||
low: `${who} opened their invitation — looks normal.`,
|
||||
medium: `${who}'s access looks a bit unusual — worth a glance.`,
|
||||
high: `${who}'s access looks suspicious — review when you can.`,
|
||||
block: `${who}'s access was blocked.`,
|
||||
}
|
||||
return {
|
||||
type: 'fraud.scored',
|
||||
ts: a.ts,
|
||||
text: friendlyText[band] || `${who}'s invitation was checked.`,
|
||||
band,
|
||||
}
|
||||
}
|
||||
|
||||
// Add-guest form
|
||||
const newGuest = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
|
||||
const addingGuest = ref(false)
|
||||
|
||||
async function addGuest() {
|
||||
addingGuest.value = true
|
||||
try {
|
||||
await useApi(`/events/${eventId}/guests`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: newGuest.name,
|
||||
email: newGuest.email || null,
|
||||
phone: newGuest.phone || null,
|
||||
plus_ones: Number(newGuest.plus_ones) || 0,
|
||||
},
|
||||
})
|
||||
Object.assign(newGuest, { name: '', email: '', phone: '', plus_ones: 0 })
|
||||
await refresh()
|
||||
} finally {
|
||||
addingGuest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Issue token
|
||||
const issued = ref<Record<string, IssuedToken>>({})
|
||||
const issuing = ref<string | null>(null)
|
||||
const copiedFor = ref<string | null>(null)
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function issueToken(guestId: string) {
|
||||
issuing.value = guestId
|
||||
try {
|
||||
const res = await useApi<IssuedToken>(`/events/${eventId}/guests/${guestId}/tokens`, {
|
||||
method: 'POST',
|
||||
})
|
||||
issued.value[guestId] = res
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
issuing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function rsvpUrl(token: string): string {
|
||||
if (import.meta.server) return ''
|
||||
return `${window.location.origin}/rsvp/${token}`
|
||||
}
|
||||
|
||||
async function copyLink(guestId: string, token: string) {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(rsvpUrl(token))
|
||||
copiedFor.value = guestId
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer)
|
||||
copyResetTimer = setTimeout(() => {
|
||||
if (copiedFor.value === guestId) copiedFor.value = null
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
console.error('clipboard copy failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Live monitor — RSVP + fraud feed via WS
|
||||
interface FeedItem {
|
||||
type: string
|
||||
ts: string
|
||||
text: string
|
||||
band?: string
|
||||
}
|
||||
const feed = ref<FeedItem[]>([])
|
||||
const wsConnected = ref(false)
|
||||
const guestById = computed(() => Object.fromEntries(guests.value.map((g) => [g.id, g])))
|
||||
|
||||
function pushFeed(item: FeedItem) {
|
||||
feed.value = [item, ...feed.value].slice(0, 50)
|
||||
}
|
||||
|
||||
let stopWS: (() => void) | null = null
|
||||
onMounted(() => {
|
||||
stopWS = useEventWS(eventId, (msg: WSMessage) => {
|
||||
wsConnected.value = true
|
||||
if (msg.type === 'rsvp.confirmed') {
|
||||
const g = guestById.value[msg.payload.guest_id]
|
||||
pushFeed({
|
||||
type: msg.type,
|
||||
ts: msg.timestamp,
|
||||
text: `${g?.name || 'Guest'} → ${msg.payload.response} (+${msg.payload.plus_ones || 0})`,
|
||||
})
|
||||
// Refresh stats and per-guest status so the counts reflect the new RSVP.
|
||||
refresh().catch(() => {})
|
||||
} else if (msg.type === 'fraud.scored') {
|
||||
const g = guestById.value[msg.payload.guest_id]
|
||||
const who = g?.name || 'A guest'
|
||||
const band = (msg.payload.risk as string) || 'low'
|
||||
// Plain-English text per band — no raw scores, no jargon.
|
||||
const friendlyText: Record<string, string> = {
|
||||
low: `${who} opened their invitation — looks normal.`,
|
||||
medium: `${who}'s access looks a bit unusual — worth a glance.`,
|
||||
high: `${who}'s access looks suspicious — review when you can.`,
|
||||
block: `${who}'s access was blocked.`,
|
||||
}
|
||||
pushFeed({
|
||||
type: msg.type,
|
||||
ts: msg.timestamp,
|
||||
text: friendlyText[band] || `${who}'s invitation was checked.`,
|
||||
band,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => stopWS?.())
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try { return new Date(iso).toLocaleTimeString() } catch { return iso }
|
||||
}
|
||||
function fmtDate(iso: string): string {
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Friendly pill label for an access-check item (replaces "fraud · {band}").
|
||||
function checkLabel(band?: string): string {
|
||||
switch (band) {
|
||||
case 'low': return 'Verified'
|
||||
case 'medium': return 'Review'
|
||||
case 'high': return 'Suspicious'
|
||||
case 'block': return 'Blocked'
|
||||
default: return 'Checked'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="text-sm text-zinc-500">Loading…</section>
|
||||
<section v-else-if="!event" class="card">Event not found.</section>
|
||||
<section v-else class="space-y-8">
|
||||
<div>
|
||||
<NuxtLink to="/dashboard" class="mb-2 inline-block text-sm text-zinc-400 hover:text-zinc-200">
|
||||
← Back to dashboard
|
||||
</NuxtLink>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{{ event.name }}</h1>
|
||||
<span class="badge bg-zinc-800 text-zinc-300">{{ event.status }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400">{{ event.venue }} · {{ fmtDate(event.event_date) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Guests + add form -->
|
||||
<div class="space-y-6">
|
||||
<div class="card">
|
||||
<h2 class="mb-3 text-lg font-semibold">Add a guest</h2>
|
||||
<form class="grid grid-cols-1 gap-3 md:grid-cols-2" @submit.prevent="addGuest">
|
||||
<input v-model="newGuest.name" placeholder="Name" class="input md:col-span-2" required />
|
||||
<input v-model="newGuest.email" type="email" placeholder="Email" class="input" />
|
||||
<input v-model="newGuest.phone" placeholder="Phone" class="input" />
|
||||
<!-- Plus-ones stepper -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="label mb-1">Plus-ones allowed</label>
|
||||
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
|
||||
:disabled="newGuest.plus_ones <= 0"
|
||||
@click="newGuest.plus_ones = Math.max(0, newGuest.plus_ones - 1)"
|
||||
>−</button>
|
||||
<span class="flex-1 text-center font-semibold tabular-nums text-zinc-100">{{ newGuest.plus_ones }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||
@click="newGuest.plus_ones++"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary md:col-span-2" :disabled="addingGuest">
|
||||
{{ addingGuest ? 'Adding…' : 'Add guest' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Guests</h2>
|
||||
<button class="text-xs text-zinc-400 hover:text-zinc-200" @click="refresh">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<button
|
||||
v-for="bucket in (['all','attending','declined','maybe','pending'] as const)"
|
||||
:key="bucket"
|
||||
class="rounded-md border px-3 py-2 text-left text-xs transition"
|
||||
:class="filter === bucket
|
||||
? 'border-brand-500 bg-brand-950/40 text-brand-200'
|
||||
: 'border-zinc-800 bg-zinc-950 text-zinc-400 hover:border-zinc-700'"
|
||||
@click="filter = bucket"
|
||||
>
|
||||
<div class="text-base font-semibold text-zinc-100">
|
||||
{{ bucket === 'all' ? stats.total : stats[bucket] }}
|
||||
</div>
|
||||
<div class="capitalize">{{ bucket }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="guests.length === 0" class="text-sm text-zinc-500">No guests yet.</div>
|
||||
<div v-else-if="filteredGuests.length === 0" class="text-sm text-zinc-500">
|
||||
No guests match this filter.
|
||||
</div>
|
||||
<ul v-else class="divide-y divide-zinc-800">
|
||||
<li v-for="g in filteredGuests" :key="g.id" class="py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-zinc-100">{{ g.name }}</span>
|
||||
<span v-if="g.rsvp_response === 'attending'" class="badge-low">attending</span>
|
||||
<span v-else-if="g.rsvp_response === 'declined'" class="badge bg-zinc-800 text-zinc-300">declined</span>
|
||||
<span v-else-if="g.rsvp_response === 'maybe'" class="badge-medium">maybe</span>
|
||||
<span v-else class="badge bg-zinc-800/60 text-zinc-500">no response</span>
|
||||
|
||||
<span
|
||||
v-if="g.rsvp_risk_score != null && g.rsvp_risk_score >= 60"
|
||||
class="badge-high"
|
||||
:title="`Risk score ${g.rsvp_risk_score}`"
|
||||
>flagged</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-zinc-500">
|
||||
{{ g.email || '—' }}
|
||||
<span v-if="g.rsvp_response">
|
||||
· bringing
|
||||
{{ g.rsvp_plus_ones ?? 0 }} of {{ g.plus_ones }} plus-ones
|
||||
</span>
|
||||
<span v-else>
|
||||
· invited +{{ g.plus_ones }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="issued[g.id]" class="mt-2 break-all rounded border border-zinc-800 bg-zinc-950 px-2 py-1 font-mono text-xs text-brand-300">
|
||||
{{ rsvpUrl(issued[g.id].token) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
v-if="!issued[g.id] && !g.rsvp_response"
|
||||
class="btn-ghost"
|
||||
:disabled="issuing === g.id || g.has_token"
|
||||
:title="g.has_token ? 'A token has already been issued for this guest' : ''"
|
||||
@click="issueToken(g.id)"
|
||||
>
|
||||
{{ issuing === g.id ? '…' : g.has_token ? 'Link issued' : 'Generate link' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="issued[g.id]"
|
||||
class="btn-ghost"
|
||||
:class="copiedFor === g.id ? 'text-brand-300' : ''"
|
||||
@click="copyLink(g.id, issued[g.id].token)"
|
||||
>
|
||||
{{ copiedFor === g.id ? 'Copied ✓' : 'Copy link' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live monitor -->
|
||||
<aside class="card flex max-h-[80vh] flex-col">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Live monitor</h2>
|
||||
<span
|
||||
class="badge"
|
||||
:class="wsConnected ? 'bg-brand-900/40 text-brand-300' : 'bg-zinc-800 text-zinc-400'"
|
||||
>
|
||||
<span class="mr-1 inline-block h-1.5 w-1.5 rounded-full" :class="wsConnected ? 'bg-brand-400' : 'bg-zinc-500'"></span>
|
||||
{{ wsConnected ? 'live' : 'connecting' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-3 text-xs text-zinc-500">
|
||||
Guest responses and security alerts, the moment they happen.
|
||||
</p>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="feed.length === 0" class="rounded-lg border border-zinc-800/50 bg-zinc-900/30 p-5 text-center">
|
||||
<p class="mb-1 text-sm font-medium text-zinc-400">All quiet for now</p>
|
||||
<p class="text-xs leading-relaxed text-zinc-600">
|
||||
Once guests start responding, their RSVPs and any<br />
|
||||
security alerts will appear here in real time.
|
||||
</p>
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, i) in feed"
|
||||
:key="`${item.type}-${item.ts}-${i}`"
|
||||
class="rounded border border-zinc-800 bg-zinc-950 p-3 text-sm"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="font-mono text-zinc-500">{{ fmtTime(item.ts) }}</span>
|
||||
<span
|
||||
v-if="item.type === 'fraud.scored'"
|
||||
:class="`badge-${item.band || 'low'}`"
|
||||
>{{ checkLabel(item.band) }}</span>
|
||||
<span v-else class="badge-low">RSVP</span>
|
||||
</div>
|
||||
<p class="text-zinc-200">{{ item.text }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user