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:
+263
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user