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
+263 -13
View File
@@ -151,7 +151,10 @@ func TestE2EHappyPath(t *testing.T) {
t.Fatalf("rsvp missing risk_score=15: %+v", rsvpResp.RSVP)
}
assertTokenUsed(t, ctx, db.Pool, guestID)
// Block A: tokens stay active after submission so the guest can come
// back and edit. The previous expectation was "used"; that's no
// longer the behaviour.
assertTokenStatus(t, ctx, db.Pool, guestID, "active")
waitForRSVPConfirmed(t, rsvpCounter, 1)
})
@@ -178,6 +181,142 @@ func TestE2EHappyPath(t *testing.T) {
assertNoRSVP(t, ctx, db.Pool, guestID)
assertTokenStatus(t, ctx, db.Pool, guestID, "active")
})
// Tier 2 Block A — editable RSVPs.
t.Run("rsvp edit records revision and survives token reuse", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "Edit Test", "edit-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Edit Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(10, "low", nil)
// First submit (POST).
first := submitRSVP(t, srv.URL, token, map[string]any{
"response": "attending",
"plus_ones": 0,
})
if first.RSVP == nil {
t.Fatalf("first submit did not return rsvp: %+v", first)
}
// Token must still be active after first submit — Block A explicitly
// drops the MarkUsed call so the guest can come back to edit.
assertTokenStatus(t, ctx, db.Pool, guestID, "active")
// /access surfaces the existing RSVP so the frontend renders an
// edit form instead of a fresh submit form.
accessAfter := getAccessFull(t, srv.URL, token)
if accessAfter.RSVP == nil {
t.Fatalf("/access did not embed existing rsvp after submit")
}
if accessAfter.RSVP.Response != "attending" {
t.Fatalf("access rsvp response: got %q want attending", accessAfter.RSVP.Response)
}
// Edit (PATCH): flip to declined, add a dietary note.
edited := editRSVP(t, srv.URL, token, map[string]any{
"response": "declined",
"plus_ones": 0,
"dietary_notes": "no longer attending",
}, http.StatusOK)
if !edited.Edited {
t.Fatalf("expected edited=true, got %+v", edited)
}
if edited.RSVP == nil || edited.RSVP.Response != "declined" {
t.Fatalf("edited rsvp not reflected: %+v", edited.RSVP)
}
if edited.RSVP.EditCount != 1 {
t.Fatalf("edit_count: got %d want 1", edited.RSVP.EditCount)
}
// One revision row, snapshotting the prior (attending) state.
assertRevisionSnapshot(t, ctx, db.Pool, guestID, "attending", 0)
// Token still active — second edit on the same token works.
assertTokenStatus(t, ctx, db.Pool, guestID, "active")
})
t.Run("rsvp edit enforces edit limit", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "Limit Test", "limit-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Limit Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(5, "low", nil)
// Initial submit.
_ = submitRSVP(t, srv.URL, token, map[string]any{
"response": "attending",
"plus_ones": 0,
})
// Five successful edits fill the quota.
for i := 0; i < 5; i++ {
next := "attending"
if i%2 == 0 {
next = "declined"
}
editRSVP(t, srv.URL, token, map[string]any{
"response": next,
"plus_ones": 0,
}, http.StatusOK)
}
// 6th edit is rejected with 429.
editRSVP(t, srv.URL, token, map[string]any{
"response": "maybe",
"plus_ones": 0,
}, http.StatusTooManyRequests)
})
t.Run("rsvp history exposes revisions to host", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "History Test", "history-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "History Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(5, "low", nil)
_ = submitRSVP(t, srv.URL, token, map[string]any{
"response": "attending",
"plus_ones": 0,
})
editRSVP(t, srv.URL, token, map[string]any{
"response": "maybe",
"plus_ones": 0,
}, http.StatusOK)
editRSVP(t, srv.URL, token, map[string]any{
"response": "declined",
"plus_ones": 0,
}, http.StatusOK)
hist := getRSVPHistory(t, srv.URL, hostToken, eventID, guestID)
if hist.RSVP == nil || hist.RSVP.Response != "declined" {
t.Fatalf("history rsvp not current: %+v", hist.RSVP)
}
if len(hist.Revisions) != 2 {
t.Fatalf("expected 2 revisions, got %d", len(hist.Revisions))
}
// Newest first — first revision snapshot is "maybe" (the value just
// before the final declined edit), not "attending".
if hist.Revisions[0].PrevResponse != "maybe" {
t.Fatalf("revisions[0]: got %q want maybe", hist.Revisions[0].PrevResponse)
}
if hist.Revisions[1].PrevResponse != "attending" {
t.Fatalf("revisions[1]: got %q want attending", hist.Revisions[1].PrevResponse)
}
})
t.Run("rsvp edit without prior submission returns 404", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostToken, "Naked Edit", "naked-edit")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Naked Edit Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(5, "low", nil)
editRSVP(t, srv.URL, token, map[string]any{
"response": "attending",
"plus_ones": 0,
}, http.StatusNotFound)
assertNoRSVP(t, ctx, db.Pool, guestID)
})
}
// --- container helpers ---
@@ -349,6 +488,129 @@ func submitRSVP(t *testing.T, base, token string, body map[string]any) submitRSV
return out
}
type editedRSVP struct {
ID uuid.UUID `json:"id"`
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
EditCount int `json:"edit_count"`
}
type editRSVPResponse struct {
RSVP *editedRSVP `json:"rsvp"`
Decision fraud.Decision `json:"fraud"`
Blocked bool `json:"blocked"`
Edited bool `json:"edited"`
}
// editRSVP fires PATCH /rsvp/{token} and asserts the response status. The
// successful path returns 200; rate-limit / not-found / 429 paths return
// the relevant status with no body assertion.
func editRSVP(t *testing.T, base, token string, body map[string]any, wantStatus int) editRSVPResponse {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPatch, base+"/rsvp/"+token, bytes.NewReader(b))
must(t, err, "build PATCH /rsvp")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
must(t, err, "do PATCH /rsvp")
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("PATCH /rsvp status=%d want=%d body=%s", resp.StatusCode, wantStatus, body)
}
if wantStatus != http.StatusOK {
return editRSVPResponse{}
}
var out editRSVPResponse
must(t, json.NewDecoder(resp.Body).Decode(&out), "decode edit rsvp")
return out
}
type accessResponseFull struct {
Token *struct {
ID uuid.UUID `json:"id"`
} `json:"token"`
AccessLog uuid.UUID `json:"access_log_id"`
RSVP *struct {
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
EditCount int `json:"edit_count"`
} `json:"rsvp"`
}
func getAccessFull(t *testing.T, base, token string) accessResponseFull {
t.Helper()
resp, err := http.Get(base + "/access/" + token)
must(t, err, "GET /access")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("GET /access status=%d body=%s", resp.StatusCode, body)
}
var out accessResponseFull
must(t, json.NewDecoder(resp.Body).Decode(&out), "decode access full")
return out
}
type rsvpHistory struct {
RSVP *struct {
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
EditCount int `json:"edit_count"`
} `json:"rsvp"`
Revisions []struct {
PrevResponse string `json:"prev_response"`
PrevPlusOnes int `json:"prev_plus_ones"`
} `json:"revisions"`
}
func getRSVPHistory(t *testing.T, base, bearer string, eventID, guestID uuid.UUID) rsvpHistory {
t.Helper()
url := fmt.Sprintf("%s/events/%s/guests/%s/rsvp/history", base, eventID, guestID)
req, err := http.NewRequest(http.MethodGet, url, nil)
must(t, err, "build history req")
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := http.DefaultClient.Do(req)
must(t, err, "do history req")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("GET history status=%d body=%s", resp.StatusCode, body)
}
var out rsvpHistory
must(t, json.NewDecoder(resp.Body).Decode(&out), "decode history")
return out
}
func assertRevisionSnapshot(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID, wantResponse string, wantPlusOnes int) {
t.Helper()
var (
gotResp string
gotPlus int
nRevs int
)
err := pool.QueryRow(ctx, `
SELECT count(*) FROM rsvp_revisions rev
JOIN rsvps r ON r.id = rev.rsvp_id
WHERE r.guest_id = $1
`, guestID).Scan(&nRevs)
must(t, err, "count revisions")
if nRevs != 1 {
t.Fatalf("expected 1 revision row, got %d", nRevs)
}
err = pool.QueryRow(ctx, `
SELECT rev.prev_response::text, rev.prev_plus_ones
FROM rsvp_revisions rev
JOIN rsvps r ON r.id = rev.rsvp_id
WHERE r.guest_id = $1
ORDER BY rev.changed_at DESC LIMIT 1
`, guestID).Scan(&gotResp, &gotPlus)
must(t, err, "load revision")
if gotResp != wantResponse || gotPlus != wantPlusOnes {
t.Fatalf("revision snapshot: got (%s, %d) want (%s, %d)", gotResp, gotPlus, wantResponse, wantPlusOnes)
}
}
func postJSON(t *testing.T, url string, body any, wantStatus int, out any) {
t.Helper()
postJSONAuthed(t, url, "", body, wantStatus, out)
@@ -450,18 +712,6 @@ func waitForFlagged(t *testing.T, ctx context.Context, pool *pgxpool.Pool, acces
t.Fatalf("access_log %s did not reach score=%d flagged=%v within 10s", accessLogID, wantScore, wantFlagged)
}
func assertTokenUsed(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID) {
t.Helper()
var status string
err := pool.QueryRow(ctx,
`SELECT status FROM tokens WHERE guest_id = $1`, guestID,
).Scan(&status)
must(t, err, "load token status")
if status != "used" {
t.Fatalf("expected token status=used for guest %s, got %s", guestID, status)
}
}
func assertTokenStatus(t *testing.T, ctx context.Context, pool *pgxpool.Pool, guestID uuid.UUID, want string) {
t.Helper()
var status string