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