59b8781659
Closes every block in docs/TIER1_PLAN.md from the Claude-scope side. The
homelab / cloud setup steps (SES verification, restore drill, lawyer-
drafted ToS) remain operator-owned but are unblocked.
Block A — Authentication
- Migration 0003: password_hash, email_verified, email_verification_tokens,
password_reset_tokens, refresh_tokens (with replaced_by family chain).
- Bcrypt hasher, HS256 JWT signer, single-use refresh tokens with rotation
+ replay-detection (revokes the family on reuse).
- /auth/signup, /login, /refresh, /logout, /verify-email,
/forgot-password, /reset-password — enumeration-safe.
- requireAuth middleware + GET /me.
- Frontend useAuth/useApi with auto-refresh-on-401, login/signup/verify/
forgot/reset pages, route-guard middleware.
Block B — Authorisation
- EventRepo.GetForHost; Update/Delete scoped by host_id.
- All host routes behind requireAuth + ownership; cross-tenant returns
404 (no enumeration). ?host_id removed.
- WS auth via short-lived single-use tickets (POST /auth/ws-ticket).
- Tests: TestCrossTenantIsolation — 9 probes.
Block C — Rate limiting
- Redis sliding-window via Lua (atomic ZADD+ZCARD+PEXPIRE).
- Per-route limits matching the plan (signup IP, login IP+email, RSVP/
access by token, events/guests/tokens by user_id).
- 429 with Retry-After header and JSON body.
- Auth lockout: 5 failed logins → account locked, only password reset
clears it.
- Frontend: useErrMessage normalises 429 + locked messaging.
Block D — Real notifications
- Migration 0004: provider_message_id, bounce_type, complained columns
+ unsubscribes (CITEXT) suppression table.
- Branded HTML + plaintext templates for verification, reset, invitation,
confirmation, reminder. Per-page templates avoid html/template's
contextual-escape collisions.
- Senders: SESv2, Twilio (SMS), SMTP (Mailpit-friendly), Resend HTTP.
- PickEmailSender priority Resend > SMTP > SES > Log — system boots
cleanly in dev with Mailpit; production flips one env var.
- Webhook endpoints (Twilio status + SES SNS) — bounces add to suppression;
signature verification stubbed pending creds.
- Auto-send: POST /tokens publishes invitation.send; notifier renders +
delivers via the configured backend; suppression list honoured.
- Bulk + per-row invitation flow: POST /events/{id}/guests/invitations/bulk
returns per-guest tokens so phone-only guests can be SMS'd manually.
- Unsubscribe: signed HMAC token (no TTL) + /unsubscribe/[token] page.
- WhatsApp Option A+: wa.me click-to-chat wizard with per-guest progress
tracking, isLikelyE164 validation, edit-from-wizard.
- Token rotate (POST /tokens/rotate) invalidates the old URL — used by
the regenerate-link flow.
- Mailpit added to docker-compose for dev inbox.
Block E — CSV import
- Streaming parser: tolerant header detection, UTF-8 BOM + UTF-16 LE/BE
decoding, row-level validation, 5,000-row cap.
- Strict E.164 phone validation with helpful error message.
- POST /preview + /import + GET /template; preview UI on event page;
atomic per-batch with dedup on existing emails.
Phone capture across UI
- PhoneInput component: country picker (~50 ISO codes) + national input +
live E.164 preview + inline length validation.
- Used in Add Guest and Edit Guest modals. Smart paste-handling extracts
country code from full E.164 strings.
Block F — Billing (Stripe)
- Migration 0005: subscriptions table (user_id → tier/status/period_end +
Stripe customer/sub ids). Partial unique index keeps one granting sub
per user.
- internal/billing: Tier + Limits model (Free 1/50, Pro 10/1000, Business
∞/5000), Stripe SDK wrapper with IgnoreAPIVersionMismatch for newer
account API versions.
- /billing/checkout-session, /billing/portal, /billing/status,
/webhooks/stripe (signature-verified, lifecycle events).
- Tier enforcement: 402 on POST /events, /guests, /import with
{error, reason, tier, used, limit, upgrade_url} body.
- Frontend: useBilling composable, /dashboard/billing page (current plan,
usage bars, tier cards), global UpgradeModal triggered by useApi's
402 interceptor.
- Customer portal kept for self-service cancel/payment-method changes.
Block G — Backups & DR (application side)
- Every migration has a tested .down.sql.
- TestMigrationRoundtrip applies all ups → all downs → all ups against a
fresh container; catches asymmetric down migrations.
- cmd/restore-verify: 28-check post-restore invariant tool (schema
presence, no orphans across 10 FK relationships, email uniqueness,
single-active subscription, row-count snapshot).
- docs/RUNBOOK_RESTORE.md: 9-step restore procedure with RTO/RPO
targets, drill instructions, rollback path.
Block H — Privacy compliance (application side)
- Migration 0006: deleted_at + terms_accepted_at + privacy_policy_accepted_at
on users. Partial index on email for live-only uniqueness.
- GET /me/data-export — synchronous JSON dump (user, events, guests,
tokens, rsvps, access_logs, notifications).
- DELETE /me — soft-delete with PII scrub + refresh-token revocation;
re-signup with same email works.
- POST /me/accept-terms — idempotent consent recording.
- Frontend /privacy + /terms placeholder pages with substantive (pending
legal review) copy; footer links; signup terms checkbox; TermsGateModal
for accounts created before the rollout; export + delete buttons on
/dashboard/billing.
Tests
- All migrations verified up/down/up.
- Integration suite: TestE2EHappyPath, TestAuthFlow, TestCrossTenantIsolation,
TestRateLimitSignup, TestLoginLockout, TestUnsubscribeFlow,
TestSESBounceWebhook, TestTwilioStatusWebhook, TestCsvImportFlow,
TestCsvImportAtomicRollback, TestBulkIssueInvitations, TestBulkIssueExplicitSubset,
TestTokenIssuePublishesInvitation, TestTokenIssueWithoutGuestEmailSkipsInvitation,
TestGuestUpdate, TestGuestDelete, TestTokenRotate, TestSMTPSenderAgainstMailpit,
TestFreeTierEventLimit, TestFreeTierGuestLimit, TestBusinessTierBypassesLimits,
TestDataExport, TestDeleteMe, TestAcceptTerms, TestMigrationRoundtrip.
Full suite runs in ~120s against real Postgres + NATS + Redis + Mailpit.
- Unit suite green across internal/auth, internal/csvimport,
internal/notification, internal/ratelimit, internal/domain.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
329 lines
14 KiB
Vue
329 lines
14 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({ middleware: ['auth'] })
|
|
|
|
const auth = useAuth()
|
|
const host = auth.user
|
|
|
|
const name = ref('')
|
|
const slug = ref('')
|
|
const venue = ref('')
|
|
const eventDate = ref('')
|
|
const maxCapacity = ref<number>(50)
|
|
const submitting = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
async function submit() {
|
|
if (!host.value) return
|
|
error.value = null
|
|
submitting.value = true
|
|
try {
|
|
const created = await useApi<{ id: string }>('/events', {
|
|
method: 'POST',
|
|
body: {
|
|
name: name.value,
|
|
slug: slug.value,
|
|
event_date: new Date(eventDate.value).toISOString(),
|
|
venue: venue.value,
|
|
max_capacity: maxCapacity.value,
|
|
},
|
|
})
|
|
await navigateTo(`/dashboard/events/${created.id}`)
|
|
} catch (e: any) {
|
|
error.value = e?.data?.error || e?.message || 'Failed to create event'
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function autoSlug() {
|
|
if (!slug.value) {
|
|
slug.value = name.value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
}
|
|
|
|
// Pretty date for the live preview card
|
|
const previewDate = computed(() => {
|
|
if (!eventDate.value) return ''
|
|
try {
|
|
return new Date(eventDate.value).toLocaleDateString(undefined, {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
} catch {
|
|
return ''
|
|
}
|
|
})
|
|
|
|
// =============================================================
|
|
// Sample mockup data — clearly NOT the user's real activity
|
|
// =============================================================
|
|
interface DemoActivity {
|
|
id: number
|
|
name: string
|
|
initials: string
|
|
action: string
|
|
extra?: string | null
|
|
tone: 'attending' | 'declined' | 'maybe'
|
|
}
|
|
|
|
const demoPool: Omit<DemoActivity, 'id'>[] = [
|
|
{ name: 'John Doe', initials: 'JD', action: 'just confirmed', extra: '+2 guests', tone: 'attending' },
|
|
{ name: 'Jane Smith', initials: 'JS', action: 'is attending', extra: null, tone: 'attending' },
|
|
{ name: 'Alex Brown', initials: 'AB', action: 'replied maybe', extra: '+1 guest', tone: 'maybe' },
|
|
{ name: 'Maria Garcia', initials: 'MG', action: 'just confirmed', extra: '+3 guests', tone: 'attending' },
|
|
{ name: 'Tom Wilson', initials: 'TW', action: 'declined', extra: null, tone: 'declined' },
|
|
{ name: 'Emma Davis', initials: 'ED', action: 'is attending', extra: '+1 guest', tone: 'attending' },
|
|
]
|
|
|
|
let demoCounter = 0
|
|
function makeActivity(idx: number): DemoActivity {
|
|
return { ...demoPool[idx % demoPool.length], id: ++demoCounter }
|
|
}
|
|
|
|
// Three visible entries; newest pushed on top, oldest drops off the bottom.
|
|
const visibleActivities = ref<DemoActivity[]>([
|
|
makeActivity(0),
|
|
makeActivity(1),
|
|
makeActivity(2),
|
|
])
|
|
const sampleConfirmed = ref(18)
|
|
|
|
let actTimer: ReturnType<typeof setInterval> | null = null
|
|
let countTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
onMounted(() => {
|
|
if (!import.meta.client) return
|
|
|
|
let idx = 3
|
|
actTimer = setInterval(() => {
|
|
visibleActivities.value = [makeActivity(idx), ...visibleActivities.value.slice(0, 2)]
|
|
idx++
|
|
}, 2800)
|
|
|
|
// Gentle tick-up on the sample stat so it feels alive
|
|
countTimer = setInterval(() => {
|
|
sampleConfirmed.value = sampleConfirmed.value >= 24 ? 18 : sampleConfirmed.value + 1
|
|
}, 4500)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (actTimer) clearInterval(actTimer)
|
|
if (countTimer) clearInterval(countTimer)
|
|
})
|
|
|
|
const toneClass: Record<DemoActivity['tone'], string> = {
|
|
attending: 'bg-brand-500/20 text-brand-300',
|
|
declined: 'bg-zinc-700/50 text-zinc-300',
|
|
maybe: 'bg-amber-500/20 text-amber-300',
|
|
}
|
|
|
|
// Capacity for the sample stat falls back to the form value when set,
|
|
// otherwise a friendly default — so the mockup feels connected to the form.
|
|
const sampleCapacity = computed(() => maxCapacity.value || 50)
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<NuxtLink to="/dashboard" class="mb-6 inline-block text-sm text-zinc-400 hover:text-zinc-200">
|
|
← Back to dashboard
|
|
</NuxtLink>
|
|
|
|
<div class="grid gap-12 lg:grid-cols-2 lg:items-start">
|
|
<!-- Left: form -->
|
|
<div>
|
|
<h1 class="mb-6 text-2xl font-semibold">Create a new event</h1>
|
|
|
|
<div v-if="!host" class="card text-sm text-zinc-400">
|
|
Please sign in first.
|
|
<NuxtLink to="/dashboard" class="text-brand-400">Go to dashboard</NuxtLink>
|
|
</div>
|
|
|
|
<form v-else class="card space-y-4" @submit.prevent="submit">
|
|
<div>
|
|
<label class="label">Event name</label>
|
|
<input v-model="name" class="input" placeholder="e.g. Sarah & James Wedding" required @blur="autoSlug" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">
|
|
Slug
|
|
<span class="ml-1 font-normal normal-case text-zinc-500">(used in the URL)</span>
|
|
</label>
|
|
<input v-model="slug" class="input" required pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="sarah-james-wedding" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Venue</label>
|
|
<input v-model="venue" class="input" placeholder="e.g. The Grand Ballroom" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Date & time</label>
|
|
<input v-model="eventDate" type="datetime-local" class="input" required />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">
|
|
Max capacity
|
|
<span class="ml-1 font-normal normal-case text-zinc-500">(guests)</span>
|
|
</label>
|
|
<input v-model.number="maxCapacity" type="number" min="1" class="input" />
|
|
</div>
|
|
|
|
<button class="btn-primary w-full" :disabled="submitting">
|
|
{{ submitting ? 'Creating…' : 'Create event →' }}
|
|
</button>
|
|
|
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- =================== RIGHT: SAMPLE PREVIEW =================== -->
|
|
<div class="hidden lg:block">
|
|
<!-- Clear preview header with sample disclaimer -->
|
|
<div class="mb-6 flex items-center justify-between gap-3">
|
|
<p class="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-brand-500">
|
|
<span class="h-px w-6 bg-brand-500"></span>
|
|
Live preview
|
|
</p>
|
|
<span
|
|
class="inline-flex items-center gap-1.5 rounded-full border border-amber-900/40 bg-amber-950/30 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-amber-300"
|
|
title="Activity shown here is illustrative — your real dashboard will use actual guest responses"
|
|
>
|
|
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z" clip-rule="evenodd" />
|
|
</svg>
|
|
Sample data
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Stack of layered floating mockup cards -->
|
|
<div class="relative mx-auto w-full max-w-sm py-6">
|
|
<!-- Soft brand glow backdrop -->
|
|
<div class="pointer-events-none absolute -inset-10 bg-gradient-to-br from-brand-500/20 via-transparent to-brand-500/10 blur-3xl"></div>
|
|
|
|
<!-- Floating sparkles -->
|
|
<div class="pointer-events-none absolute -left-3 top-10 h-2 w-2 rounded-full bg-brand-400 opacity-70" style="animation: gg-ping 2.6s cubic-bezier(0,0,.2,1) infinite"></div>
|
|
<div class="pointer-events-none absolute right-4 -top-2 h-1.5 w-1.5 rounded-full bg-brand-300 opacity-70" style="animation: gg-ping 3.2s cubic-bezier(0,0,.2,1) infinite; animation-delay: .6s"></div>
|
|
<div class="pointer-events-none absolute -right-2 bottom-24 h-2 w-2 rounded-full bg-brand-500 opacity-60" style="animation: gg-ping 3.6s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.2s"></div>
|
|
<div class="pointer-events-none absolute left-6 -bottom-2 h-1.5 w-1.5 rounded-full bg-brand-400 opacity-60" style="animation: gg-ping 2.8s cubic-bezier(0,0,.2,1) infinite; animation-delay: 1.8s"></div>
|
|
|
|
<!-- 1. Sample stats card (top-right, sample badge attached) -->
|
|
<div
|
|
class="absolute -right-2 -top-6 z-20 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur md:right-0"
|
|
style="animation: gg-float-cw 5.5s ease-in-out infinite"
|
|
>
|
|
<p class="mb-1.5 flex items-center justify-between gap-3 text-[10px] font-medium uppercase tracking-wider">
|
|
<span class="flex items-center gap-1.5 text-brand-400">
|
|
<span class="relative flex h-1.5 w-1.5">
|
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-75"></span>
|
|
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
|
</span>
|
|
RSVPs
|
|
</span>
|
|
<span class="text-[9px] text-zinc-600">sample</span>
|
|
</p>
|
|
<div class="flex items-baseline gap-2 text-zinc-100">
|
|
<span class="text-2xl font-bold tabular-nums transition-all duration-300">{{ sampleConfirmed }}</span>
|
|
<span class="text-xs text-zinc-500">of {{ sampleCapacity }} confirmed</span>
|
|
</div>
|
|
<div class="mt-2 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
|
|
<div
|
|
class="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-400 transition-all duration-700 ease-out"
|
|
:style="{ width: `${Math.min(100, (sampleConfirmed / sampleCapacity) * 100)}%` }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2. The user's actual invitation preview (centre, "Your event" badge) -->
|
|
<div
|
|
class="relative z-10 overflow-hidden rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900 via-zinc-900 to-zinc-950 shadow-2xl"
|
|
style="animation: gg-float-ccw 6s ease-in-out infinite"
|
|
>
|
|
<div class="h-1 bg-gradient-to-r from-brand-600 via-brand-400 to-brand-600"></div>
|
|
<div class="p-6">
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<p class="text-[10px] font-medium uppercase tracking-[0.22em] text-brand-400">
|
|
✦ You're Invited
|
|
</p>
|
|
<span class="rounded-full border border-brand-800/60 bg-brand-950/40 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-brand-400">
|
|
Your event
|
|
</span>
|
|
</div>
|
|
<h3 class="mb-1 truncate text-lg font-semibold text-zinc-100">
|
|
{{ name || 'Your event title' }}
|
|
</h3>
|
|
<p class="mb-4 text-xs text-zinc-500">
|
|
<span :class="venue ? 'text-zinc-400' : ''">{{ venue || 'Venue' }}</span>
|
|
·
|
|
<span :class="previewDate ? 'text-zinc-400' : ''">{{ previewDate || 'Date' }}</span>
|
|
</p>
|
|
|
|
<p class="mb-2 text-xs text-zinc-400">Will you be there?</p>
|
|
<div class="flex gap-1.5">
|
|
<span class="flex-1 rounded-md border border-brand-700/60 bg-brand-950/40 px-2 py-1.5 text-center text-xs font-medium text-brand-300">Attending</span>
|
|
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Maybe</span>
|
|
<span class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-center text-xs text-zinc-500">Decline</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 3. Recent activity list (below the invitation, slight tilt + float) -->
|
|
<div
|
|
class="relative z-20 mx-1 mt-4 rounded-xl border border-zinc-800 bg-zinc-900/95 px-4 py-3.5 shadow-2xl backdrop-blur"
|
|
style="animation: gg-float-cw-sm 5.4s ease-in-out infinite; animation-delay: .3s"
|
|
>
|
|
<div class="mb-3 flex items-center justify-between gap-2">
|
|
<p class="flex items-center gap-1.5 text-xs font-medium text-zinc-300">
|
|
<span class="relative flex h-1.5 w-1.5">
|
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-60"></span>
|
|
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand-400"></span>
|
|
</span>
|
|
Recent activity
|
|
</p>
|
|
<span class="rounded-full border border-amber-900/40 bg-amber-950/30 px-2 py-0.5 text-[9px] font-medium uppercase tracking-wider text-amber-300">
|
|
Sample
|
|
</span>
|
|
</div>
|
|
|
|
<TransitionGroup
|
|
tag="ul"
|
|
class="relative space-y-3"
|
|
enter-active-class="transition-all duration-500 ease-out"
|
|
enter-from-class="-translate-y-2 opacity-0"
|
|
enter-to-class="translate-y-0 opacity-100"
|
|
leave-active-class="transition-all duration-300 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="translate-y-2 opacity-0"
|
|
>
|
|
<li
|
|
v-for="item in visibleActivities"
|
|
:key="item.id"
|
|
class="flex items-start gap-2.5 text-xs"
|
|
>
|
|
<span
|
|
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
|
|
:class="toneClass[item.tone]"
|
|
>
|
|
{{ item.initials }}
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-zinc-300">
|
|
<span class="font-medium text-zinc-100">{{ item.name }}</span>
|
|
<span class="text-zinc-500"> {{ item.action }}</span>
|
|
<span v-if="item.extra" class="text-brand-400"> · {{ item.extra }}</span>
|
|
</p>
|
|
</div>
|
|
</li>
|
|
</TransitionGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|