feat(tier2): editable RSVPs — Block A

Guests can revisit their invitation link and change their response
or plus-ones up to 5 times. Each prior state is snapshotted into
`rsvp_revisions` and surfaced to the host via a per-guest history
modal on the event detail page.

- Migration 0007 adds rsvp_revisions + rsvps.edit_count (with down)
- RSVPRepo.Update wraps snapshot+update+counter in one transaction,
  FOR UPDATE-locking the row so concurrent edits can't bypass the cap
- PATCH /rsvp/{token} re-runs the fraud check on every edit attempt
  (different device on an edit is itself a signal)
- POST /rsvp no longer marks the token used — the link stays valid
  so the guest can come back to edit
- GET /access/{token} now embeds the existing RSVP so the frontend
  renders an edit form instead of a blank submit form on revisit
- New host endpoint GET /events/{id}/guests/{guest_id}/rsvp/history
- Frontend: rsvp/[token].vue toggles between summary + edit form,
  surfaces edits-remaining; dashboard adds a "History" action on
  responded guests opening a revision-trail modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 19:27:50 +01:00
parent e087fd53ef
commit 39533162bb
10 changed files with 1018 additions and 109 deletions
+124 -12
View File
@@ -1,17 +1,30 @@
<script setup lang="ts">
interface ExistingRSVP {
id: string
response: 'attending' | 'declined' | 'maybe'
plus_ones: number
dietary_notes?: string | null
submitted_at: string
edit_count: number
}
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
rsvp?: ExistingRSVP | null
}
interface RSVPSubmitResponse {
rsvp?: { id: string; response: string; plus_ones: number; risk_score: number }
rsvp?: ExistingRSVP & { risk_score?: number }
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
blocked: boolean
edited?: boolean
}
const MAX_EDITS = 5
const route = useRoute()
const token = route.params.token as string
@@ -27,10 +40,32 @@ const submitting = ref(false)
const result = ref<RSVPSubmitResponse | null>(null)
const submitError = ref<string | null>(null)
// existing tracks the RSVP that was on file when the page loaded.
// editing toggles the form open over the "already responded" summary.
const existing = ref<ExistingRSVP | null>(null)
const editing = ref(false)
const editsRemaining = computed(() => {
const used = existing.value?.edit_count ?? 0
return Math.max(0, MAX_EDITS - used)
})
const editLimitReached = computed(() => editsRemaining.value <= 0)
function prefillFromRSVP(rsvp: ExistingRSVP) {
response.value = rsvp.response
plusOnes.value = rsvp.plus_ones
dietary.value = rsvp.dietary_notes ?? ''
}
onMounted(async () => {
try {
access.value = await useApi<AccessResponse>(`/access/${token}`)
if (access.value) plusOnes.value = access.value.guest.plus_ones || 0
if (!access.value) return
plusOnes.value = access.value.guest.plus_ones || 0
if (access.value.rsvp) {
existing.value = access.value.rsvp
prefillFromRSVP(access.value.rsvp)
}
} catch (e: any) {
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
} finally {
@@ -41,10 +76,11 @@ onMounted(async () => {
async function submit() {
submitting.value = true
submitError.value = null
const isEdit = !!existing.value
try {
const fp = useFingerprint()
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
method: 'POST',
method: isEdit ? 'PATCH' : 'POST',
body: {
response: response.value,
plus_ones: plusOnes.value,
@@ -52,8 +88,22 @@ async function submit() {
fingerprint: fp,
},
})
// Refresh the cached "existing" view so a back-to-summary toggle shows
// the new state and the edit counter without a page reload.
if (result.value?.rsvp) {
existing.value = {
id: result.value.rsvp.id,
response: result.value.rsvp.response,
plus_ones: result.value.rsvp.plus_ones,
dietary_notes: result.value.rsvp.dietary_notes ?? null,
submitted_at: result.value.rsvp.submitted_at,
edit_count: result.value.rsvp.edit_count,
}
}
editing.value = false
} catch (e: any) {
// 403 from BLOCK band returns a JSON body; surface its decision too.
// BLOCK band returns 403 with the fraud decision; surface it the same
// way the first-submit path does.
if (e?.data?.fraud) {
result.value = e.data
} else {
@@ -64,10 +114,28 @@ async function submit() {
}
}
function startEditing() {
if (existing.value) prefillFromRSVP(existing.value)
result.value = null
editing.value = true
}
function cancelEditing() {
if (existing.value) prefillFromRSVP(existing.value)
editing.value = false
}
function fmtDate(iso?: string) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// showForm = no prior submission, or the guest has clicked "Change my response"
const showForm = computed(() => editing.value || !existing.value)
const submitLabel = computed(() => {
if (submitting.value) return existing.value ? 'Updating…' : 'Submitting…'
return existing.value ? 'Update RSVP' : 'Submit RSVP'
})
</script>
<template>
@@ -89,8 +157,10 @@ function fmtDate(iso?: string) {
</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>
<div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20">
<h1 class="mb-2 text-xl font-semibold text-brand-200">
{{ result.edited ? 'Your response has been updated' : "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.
@@ -99,10 +169,45 @@ function fmtDate(iso?: string) {
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
<span v-if="!result.fraud.used"> · fallback</span>
</p>
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
<p class="text-xs text-zinc-500">
Need to change something? You have {{ editsRemaining }}
{{ editsRemaining === 1 ? 'edit' : 'edits' }} left.
</p>
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
</div>
<p v-else class="mt-5 border-t border-zinc-800 pt-4 text-xs text-zinc-500">
You've used all {{ MAX_EDITS }} edits on this invitation — contact your host if you need
to change anything else.
</p>
</div>
<div v-else-if="access" class="card">
<p class="text-xs uppercase tracking-widest text-brand-500">Invitation</p>
<div v-else-if="existing && !editing" class="card border-brand-900/60 bg-brand-950/20">
<p class="text-xs uppercase tracking-widest text-brand-500">RSVP on file</p>
<h1 class="mb-1 text-2xl font-semibold">{{ access?.event.name }}</h1>
<p class="mb-5 text-sm text-zinc-400">
{{ access?.event.venue }} · {{ fmtDate(access?.event.event_date) }}
</p>
<p class="mb-4 text-sm">
You responded <strong class="capitalize">{{ existing.response }}</strong>
<span v-if="existing.plus_ones > 0"> with +{{ existing.plus_ones }} plus-ones</span>
on {{ fmtDate(existing.submitted_at) }}.
</p>
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
<p class="text-xs text-zinc-500">
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
</p>
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
</div>
<p v-else class="border-t border-zinc-800 pt-4 text-xs text-zinc-500">
You've used all {{ MAX_EDITS }} edits on this invitation.
</p>
</div>
<div v-else-if="access && showForm" class="card">
<p class="text-xs uppercase tracking-widest text-brand-500">
{{ existing ? 'Update your response' : '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) }}
@@ -110,7 +215,9 @@ function fmtDate(iso?: string) {
<p class="mb-6 text-sm">
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span>
please confirm your response below.
<template v-if="existing">change your response below {{ editsRemaining }}
{{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.</template>
<template v-else>please confirm your response below.</template>
</p>
<div class="mb-4">
@@ -159,9 +266,14 @@ function fmtDate(iso?: string) {
<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>
<div class="flex items-center gap-3">
<button class="btn-primary flex-1" :disabled="submitting" @click="submit">
{{ submitLabel }}
</button>
<button v-if="existing" type="button" class="btn-ghost" :disabled="submitting" @click="cancelEditing">
Cancel
</button>
</div>
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
</div>