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
@@ -0,0 +1,4 @@
ALTER TABLE rsvps DROP COLUMN IF EXISTS edit_count;
DROP INDEX IF EXISTS idx_rsvp_revisions_rsvp;
DROP TABLE IF EXISTS rsvp_revisions;
@@ -0,0 +1,25 @@
-- Tier 2 Block A — editable RSVPs.
--
-- Guests can revisit their invitation link after submitting and change their
-- response or plus-one count. Every prior state is captured in rsvp_revisions
-- so the host can see the trail of edits.
--
-- The plan called this 0004_rsvp_edits in TIER2_PLAN.md; using the next free
-- slot (0007) because 00040006 were taken by Tier 1 work.
CREATE TABLE IF NOT EXISTS rsvp_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rsvp_id UUID NOT NULL REFERENCES rsvps(id) ON DELETE CASCADE,
prev_response rsvp_response NOT NULL,
prev_plus_ones INTEGER NOT NULL,
prev_dietary TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rsvp_revisions_rsvp
ON rsvp_revisions(rsvp_id, changed_at DESC);
-- Hard cap on how many times a guest can edit. The numeric cap (5) is
-- enforced in Go; storing the running count avoids a count(*) on every PATCH.
ALTER TABLE rsvps
ADD COLUMN IF NOT EXISTS edit_count SMALLINT NOT NULL DEFAULT 0;
+150 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@@ -52,7 +53,8 @@ func (r *RSVPRepo) Create(ctx context.Context, p CreateRSVPParams) (*domain.RSVP
device_fingerprint, ip_address, risk_score)
VALUES ($1, $2, $3, $4, $5, $6::inet, $7)
RETURNING id, guest_id, response, plus_ones, dietary_notes,
submitted_at, device_fingerprint, ip_address::text, risk_score
submitted_at, device_fingerprint, ip_address::text, risk_score,
edit_count
`
row := r.pool.QueryRow(ctx, q,
@@ -71,6 +73,152 @@ func (r *RSVPRepo) Create(ctx context.Context, p CreateRSVPParams) (*domain.RSVP
return rs, nil
}
// GetByGuest returns the RSVP submitted by `guestID`, or ErrRSVPNotFound when
// none exists yet. Used by /access/{token} to surface the current submission
// so the frontend can show an edit form, and by PATCH /rsvp to load the row
// being revised.
func (r *RSVPRepo) GetByGuest(ctx context.Context, guestID uuid.UUID) (*domain.RSVP, error) {
const q = `
SELECT id, guest_id, response, plus_ones, dietary_notes,
submitted_at, device_fingerprint, ip_address::text, risk_score,
edit_count
FROM rsvps WHERE guest_id = $1
`
rs, err := scanRSVP(r.pool.QueryRow(ctx, q, guestID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrRSVPNotFound
}
return nil, err
}
return rs, nil
}
type UpdateRSVPParams struct {
GuestID uuid.UUID
Response domain.RSVPResponse
PlusOnes int
DietaryNotes *string
DeviceFingerprint map[string]any
IPAddress string
RiskScore *int
}
// Update applies a revision to the guest's RSVP. The previous values are
// snapshotted into rsvp_revisions inside the same transaction so the history
// is consistent — either both the snapshot and the new state land, or neither
// does. Returns ErrRSVPEditLimitReached if the guest has already hit
// MaxRSVPEdits; the row itself is left untouched.
func (r *RSVPRepo) Update(ctx context.Context, p UpdateRSVPParams) (*domain.RSVP, error) {
var fpJSON []byte
if p.DeviceFingerprint != nil {
b, err := json.Marshal(p.DeviceFingerprint)
if err != nil {
return nil, fmt.Errorf("marshal fingerprint: %w", err)
}
fpJSON = b
}
var ip *string
if p.IPAddress != "" {
ip = &p.IPAddress
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// SELECT ... FOR UPDATE locks the row so two concurrent edits can't both
// snapshot the same prior state and both increment edit_count past the cap.
var (
rsvpID uuid.UUID
prevResp domain.RSVPResponse
prevPlusOnes int
prevDietary *string
editCount int
)
err = tx.QueryRow(ctx, `
SELECT id, response, plus_ones, dietary_notes, edit_count
FROM rsvps WHERE guest_id = $1
FOR UPDATE
`, p.GuestID).Scan(&rsvpID, &prevResp, &prevPlusOnes, &prevDietary, &editCount)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrRSVPNotFound
}
return nil, err
}
if editCount >= domain.MaxRSVPEdits {
return nil, domain.ErrRSVPEditLimitReached
}
if _, err := tx.Exec(ctx, `
INSERT INTO rsvp_revisions (rsvp_id, prev_response, prev_plus_ones, prev_dietary)
VALUES ($1, $2, $3, $4)
`, rsvpID, prevResp, prevPlusOnes, prevDietary); err != nil {
return nil, fmt.Errorf("snapshot revision: %w", err)
}
const upd = `
UPDATE rsvps
SET response = $2,
plus_ones = $3,
dietary_notes = $4,
device_fingerprint = COALESCE($5, device_fingerprint),
ip_address = COALESCE($6::inet, ip_address),
risk_score = COALESCE($7, risk_score),
submitted_at = now(),
edit_count = edit_count + 1
WHERE guest_id = $1
RETURNING id, guest_id, response, plus_ones, dietary_notes,
submitted_at, device_fingerprint, ip_address::text, risk_score,
edit_count
`
row := tx.QueryRow(ctx, upd,
p.GuestID, p.Response, p.PlusOnes, p.DietaryNotes,
fpJSON, ip, p.RiskScore,
)
rs, err := scanRSVP(row)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return rs, nil
}
// ListRevisions returns every prior state of an RSVP, newest first. Empty
// slice (not nil) when there are no revisions, so the JSON encodes as `[]`.
func (r *RSVPRepo) ListRevisions(ctx context.Context, rsvpID uuid.UUID) ([]domain.RSVPRevision, error) {
const q = `
SELECT id, rsvp_id, prev_response, prev_plus_ones, prev_dietary, changed_at
FROM rsvp_revisions
WHERE rsvp_id = $1
ORDER BY changed_at DESC
`
rows, err := r.pool.Query(ctx, q, rsvpID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []domain.RSVPRevision{}
for rows.Next() {
var rev domain.RSVPRevision
if err := rows.Scan(
&rev.ID, &rev.RSVPID, &rev.PrevResponse,
&rev.PrevPlusOnes, &rev.PrevDietary, &rev.ChangedAt,
); err != nil {
return nil, err
}
out = append(out, rev)
}
return out, rows.Err()
}
// RSVPActivity is a denormalised RSVP entry for the activity feed —
// includes the guest's name so the API can hand it to the frontend
// without a separate lookup.
@@ -120,7 +268,7 @@ func scanRSVP(s rowScanner) (*domain.RSVP, error) {
)
err := s.Scan(
&rs.ID, &rs.GuestID, &rs.Response, &rs.PlusOnes, &rs.DietaryNotes,
&rs.SubmittedAt, &fpJSON, &ip, &rs.RiskScore,
&rs.SubmittedAt, &fpJSON, &ip, &rs.RiskScore, &rs.EditCount,
)
if err != nil {
return nil, err