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