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
+26 -4
View File
@@ -32,6 +32,7 @@ type tokenHandler struct {
events *storage.EventRepo
users *storage.UserRepo
accessLogs *storage.AccessLogRepo
rsvps *storage.RSVPRepo
gen *auth.Generator
ttl time.Duration
pub accessPublisher
@@ -401,10 +402,14 @@ func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) {
}
type accessResponse struct {
Guest *domain.Guest `json:"guest"`
Event *domain.Event `json:"event"`
Token *domain.Token `json:"token"`
AccessLog uuid.UUID `json:"access_log_id"`
Guest *domain.Guest `json:"guest"`
Event *domain.Event `json:"event"`
Token *domain.Token `json:"token"`
AccessLog uuid.UUID `json:"access_log_id"`
// RSVP is the guest's current submission, if any. Populated so the RSVP
// page can show an edit form instead of a fresh submit form when the
// guest revisits their invitation link (Tier 2 Block A).
RSVP *domain.RSVP `json:"rsvp,omitempty"`
}
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
@@ -471,11 +476,28 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
OccurredAt: time.Now().UTC(),
})
var existingRSVP *domain.RSVP
if h.rsvps != nil {
// Best-effort: a missing RSVP just means the guest hasn't submitted
// yet. Any other error is logged but doesn't fail the access call —
// we'd rather the guest see the form than a 500.
rs, err := h.rsvps.GetByGuest(r.Context(), guest.ID)
switch {
case err == nil:
existingRSVP = rs
case errors.Is(err, domain.ErrRSVPNotFound):
// expected first-visit case
default:
h.logger.Warn("load rsvp for access", "err", err, "guest_id", guest.ID)
}
}
writeJSON(w, http.StatusOK, accessResponse{
Guest: guest,
Event: event,
Token: tk,
AccessLog: accessLogID,
RSVP: existingRSVP,
})
}