Files
guestguard/frontend/pages/dashboard/events/[id].vue
T
Kwaku Danso 3f8bc58ca9 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>
2026-05-11 21:08:56 +01:00

446 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>