Files
guestguard/frontend/pages/rsvp/[token].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

170 lines
6.1 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 AccessResponse {
guest: { id: string; name: string; email?: string | null; plus_ones: number }
event: { id: string; name: string; venue: string; event_date: string }
token: { id: string; status: string; expires_at: string }
access_log_id: string
}
interface RSVPSubmitResponse {
rsvp?: { id: string; response: string; plus_ones: number; risk_score: number }
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
blocked: boolean
}
const route = useRoute()
const token = route.params.token as string
const loading = ref(true)
const access = ref<AccessResponse | null>(null)
const loadError = ref<string | null>(null)
const response = ref<'attending' | 'declined' | 'maybe'>('attending')
const plusOnes = ref(0)
const dietary = ref('')
const submitting = ref(false)
const result = ref<RSVPSubmitResponse | null>(null)
const submitError = ref<string | null>(null)
onMounted(async () => {
try {
access.value = await useApi<AccessResponse>(`/access/${token}`)
if (access.value) plusOnes.value = access.value.guest.plus_ones || 0
} catch (e: any) {
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
} finally {
loading.value = false
}
})
async function submit() {
submitting.value = true
submitError.value = null
try {
const fp = useFingerprint()
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
method: 'POST',
body: {
response: response.value,
plus_ones: plusOnes.value,
dietary_notes: dietary.value || null,
fingerprint: fp,
},
})
} catch (e: any) {
// 403 from BLOCK band returns a JSON body; surface its decision too.
if (e?.data?.fraud) {
result.value = e.data
} else {
submitError.value = e?.data?.error || e?.message || 'Could not submit RSVP'
}
} finally {
submitting.value = false
}
}
function fmtDate(iso?: string) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
</script>
<template>
<section class="mx-auto max-w-xl py-8">
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation</div>
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
<h1 class="mb-2 text-xl font-semibold text-red-200">Invitation unavailable</h1>
<p class="text-sm text-red-300">{{ loadError }}</p>
</div>
<div v-else-if="result?.blocked" class="card border-red-900/60 bg-red-950/30">
<h1 class="mb-2 text-xl font-semibold text-red-200">This invitation cannot be used</h1>
<p class="text-sm text-red-300">
The host has been notified of a suspicious access attempt.
</p>
<p class="mt-3 text-xs text-red-400">
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
</p>
</div>
<div v-else-if="result?.rsvp" class="card border-brand-900/60 bg-brand-950/20">
<h1 class="mb-2 text-xl font-semibold text-brand-200">You're confirmed</h1>
<p class="text-sm text-brand-300">
Response recorded as <strong>{{ result.rsvp.response }}</strong> with
+{{ result.rsvp.plus_ones }} plus-ones.
</p>
<p class="mt-3 text-xs text-zinc-500">
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
<span v-if="!result.fraud.used"> · fallback</span>
</p>
</div>
<div v-else-if="access" class="card">
<p class="text-xs uppercase tracking-widest text-brand-500">Invitation</p>
<h1 class="mb-1 text-2xl font-semibold">{{ access.event.name }}</h1>
<p class="mb-6 text-sm text-zinc-400">
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
</p>
<p class="mb-6 text-sm">
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
please confirm your response below.
</p>
<div class="mb-4">
<label class="label">Response</label>
<div class="flex gap-2">
<button
v-for="opt in (['attending', 'declined', 'maybe'] as const)"
:key="opt"
type="button"
class="btn-ghost flex-1 capitalize"
:class="response === opt ? 'border border-brand-500 text-brand-300' : 'border border-zinc-800'"
@click="response = opt"
>{{ opt }}</button>
</div>
</div>
<div v-if="access.guest.plus_ones > 0" class="mb-4">
<label class="label">
Plus-ones
<span class="ml-1 font-normal normal-case text-zinc-500">
(you may bring up to {{ access.guest.plus_ones }})
</span>
</label>
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
<button
type="button"
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
:disabled="plusOnes <= 0"
@click="plusOnes = Math.max(0, plusOnes - 1)"
></button>
<span class="flex-1 text-center text-base font-semibold tabular-nums text-zinc-100">{{ plusOnes }}</span>
<button
type="button"
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
:disabled="plusOnes >= access.guest.plus_ones"
@click="plusOnes = Math.min(access.guest.plus_ones, plusOnes + 1)"
>+</button>
</div>
</div>
<p v-else class="mb-4 text-xs text-zinc-500">
This invitation is for one person only — no plus-ones for this one.
</p>
<div class="mb-6">
<label class="label">Dietary notes (optional)</label>
<input v-model="dietary" class="input" placeholder="e.g. vegetarian" />
</div>
<button class="btn-primary w-full" :disabled="submitting" @click="submit">
{{ submitting ? 'Submitting' : 'Submit RSVP' }}
</button>
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
</div>
</section>
</template>