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
+136 -1
View File
@@ -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"