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:
@@ -412,6 +412,52 @@ async function confirmDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// RSVP edit history (Tier 2 Block A). Lazy-loaded — we only fetch revisions
|
||||
// when the host clicks "History" on a guest, so a 200-row event list stays
|
||||
// cheap. The current RSVP is in historyData.rsvp; revisions are newest-first.
|
||||
interface RSVPRevision {
|
||||
id: string
|
||||
rsvp_id: string
|
||||
prev_response: 'attending' | 'declined' | 'maybe'
|
||||
prev_plus_ones: number
|
||||
prev_dietary?: string | null
|
||||
changed_at: string
|
||||
}
|
||||
interface RSVPHistoryResponse {
|
||||
rsvp: {
|
||||
response: 'attending' | 'declined' | 'maybe'
|
||||
plus_ones: number
|
||||
dietary_notes?: string | null
|
||||
submitted_at: string
|
||||
edit_count: number
|
||||
} | null
|
||||
revisions: RSVPRevision[]
|
||||
}
|
||||
const historyFor = ref<Guest | null>(null)
|
||||
const historyData = ref<RSVPHistoryResponse | null>(null)
|
||||
const historyLoading = ref(false)
|
||||
const historyError = ref<string | null>(null)
|
||||
async function openHistory(g: Guest) {
|
||||
historyFor.value = g
|
||||
historyData.value = null
|
||||
historyError.value = null
|
||||
historyLoading.value = true
|
||||
try {
|
||||
historyData.value = await useApi<RSVPHistoryResponse>(
|
||||
`/events/${eventId}/guests/${g.id}/rsvp/history`,
|
||||
)
|
||||
} catch (e: any) {
|
||||
historyError.value = useErrMessage(e, 'Could not load history')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
function closeHistory() {
|
||||
historyFor.value = null
|
||||
historyData.value = null
|
||||
historyError.value = null
|
||||
}
|
||||
|
||||
// Regenerate-link flow. The modal stays open after click so the host can
|
||||
// see the new link copy succeed; closing returns to the list.
|
||||
const regenerating = ref<Guest | null>(null)
|
||||
@@ -456,10 +502,11 @@ async function regenerateAndCopy() {
|
||||
// Esc closes any open modal — standard expectation for dialog UI.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return
|
||||
if (editing.value || deleting.value || regenerating.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) {
|
||||
if (editing.value || deleting.value || regenerating.value || historyFor.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) {
|
||||
editing.value = null
|
||||
deleting.value = null
|
||||
regenerating.value = null
|
||||
closeHistory()
|
||||
addingGuestOpen.value = false
|
||||
importingOpen.value = false
|
||||
whatsappOpen.value = false
|
||||
@@ -1001,6 +1048,23 @@ function checkLabel(band?: string): string {
|
||||
New link
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="g.rsvp_response">
|
||||
<!-- Responded guests: show edit history. Useful when
|
||||
"attending" silently flips to "declined" and the
|
||||
host wants to know when. -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-zinc-700 px-2 py-1 text-xs font-medium text-zinc-300 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
|
||||
:title="`View the edit trail for ${g.name}`"
|
||||
:aria-label="`View RSVP history for ${g.name}`"
|
||||
@click.stop="openHistory(g)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM10 4a1 1 0 011 1v5l3 1a1 1 0 11-.5 1.93l-3.5-1.16A1 1 0 019 10.83V5a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
History
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1424,6 +1488,77 @@ function checkLabel(band?: string): string {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- RSVP edit history (Tier 2 Block A) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="historyFor"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
@click.self="closeHistory"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="history-title"
|
||||
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
|
||||
>
|
||||
<h3 id="history-title" class="mb-1 text-base font-semibold">
|
||||
RSVP history — {{ historyFor.name }}
|
||||
</h3>
|
||||
<p class="mb-4 text-xs text-zinc-500">
|
||||
Each edit replaces the previous response. The trail below is read-only.
|
||||
</p>
|
||||
|
||||
<div v-if="historyLoading" class="py-6 text-center text-sm text-zinc-500">Loading…</div>
|
||||
<div v-else-if="historyError" class="rounded-md border border-red-900/60 bg-red-950/30 p-3 text-sm text-red-300">
|
||||
{{ historyError }}
|
||||
</div>
|
||||
<div v-else-if="historyData && historyData.rsvp" class="space-y-3">
|
||||
<div class="rounded-md border border-brand-900/60 bg-brand-950/20 p-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-brand-300">
|
||||
<span class="font-medium uppercase tracking-wider">Current</span>
|
||||
<span class="text-zinc-500">{{ fmtTime(historyData.rsvp.submitted_at) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-200">
|
||||
<strong class="capitalize">{{ historyData.rsvp.response }}</strong>
|
||||
<span v-if="historyData.rsvp.plus_ones > 0"> · +{{ historyData.rsvp.plus_ones }} plus-ones</span>
|
||||
<span v-if="historyData.rsvp.dietary_notes"> · {{ historyData.rsvp.dietary_notes }}</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
{{ historyData.rsvp.edit_count }} edit{{ historyData.rsvp.edit_count === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="historyData.revisions.length === 0" class="py-3 text-center text-xs text-zinc-500">
|
||||
No edits yet.
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="rev in historyData.revisions"
|
||||
:key="rev.id"
|
||||
class="rounded-md border border-zinc-800 bg-zinc-950 p-3"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>was</span>
|
||||
<span>{{ fmtTime(rev.changed_at) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-300">
|
||||
<strong class="capitalize">{{ rev.prev_response }}</strong>
|
||||
<span v-if="rev.prev_plus_ones > 0"> · +{{ rev.prev_plus_ones }} plus-ones</span>
|
||||
<span v-if="rev.prev_dietary"> · {{ rev.prev_dietary }}</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="py-3 text-center text-sm text-zinc-500">
|
||||
No RSVP has been submitted yet.
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end">
|
||||
<button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" @click="closeHistory">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Toast: bulk-send result. Fades after ~5s; click to dismiss. -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
|
||||
+124
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user