-
Invitation
+
+
RSVP on file
+
{{ access?.event.name }}
+
+ {{ access?.event.venue }} · {{ fmtDate(access?.event.event_date) }}
+
+
+ You responded {{ existing.response }}
+ with +{{ existing.plus_ones }} plus-ones
+ on {{ fmtDate(existing.submitted_at) }}.
+
+
+
+ {{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
+
+
+
+
+ You've used all {{ MAX_EDITS }} edits on this invitation.
+
+
+
+
+
+ {{ existing ? 'Update your response' : 'Invitation' }}
+
{{ access.event.name }}
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
@@ -110,7 +215,9 @@ function fmtDate(iso?: string) {
Hi {{ access.guest.name }} —
- please confirm your response below.
+ change your response below — {{ editsRemaining }}
+ {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
+ please confirm your response below.
@@ -159,9 +266,14 @@ function fmtDate(iso?: string) {
-
+
+
+
+
{{ submitError }}
diff --git a/internal/api/rsvps.go b/internal/api/rsvps.go
index 0247f13..24dbd36 100644
--- a/internal/api/rsvps.go
+++ b/internal/api/rsvps.go
@@ -9,6 +9,8 @@ import (
"net/http"
"time"
+ "github.com/google/uuid"
+
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/fraud"
@@ -43,51 +45,24 @@ type submitRSVPRequest struct {
}
type submitRSVPResponse struct {
- RSVP *domain.RSVP `json:"rsvp"`
- Decision fraud.Decision `json:"fraud"`
- Blocked bool `json:"blocked"`
+ RSVP *domain.RSVP `json:"rsvp"`
+ Decision fraud.Decision `json:"fraud"`
+ Blocked bool `json:"blocked"`
+ Edited bool `json:"edited"`
}
// POST /rsvp/{token} — synchronous fraud check + RSVP recording.
func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
- raw := r.PathValue("token")
- if err := auth.ValidateFormat(raw); err != nil {
- writeError(w, http.StatusBadRequest, "malformed token")
+ tk, ok := h.loadValidToken(w, r)
+ if !ok {
return
}
-
- tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
- if err != nil {
- if errors.Is(err, domain.ErrTokenNotFound) {
- writeError(w, http.StatusNotFound, "token not found")
- return
- }
- writeError(w, http.StatusInternalServerError, "failed to load token")
+ req, ok := decodeSubmitRSVP(w, r)
+ if !ok {
return
}
- if err := tk.IsValid(time.Now().UTC()); err != nil {
- writeError(w, http.StatusGone, err.Error())
- return
- }
-
- var req submitRSVPRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeError(w, http.StatusBadRequest, "invalid json")
- return
- }
- resp := domain.RSVPResponse(req.Response)
- if !resp.Valid() {
- writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
- return
- }
- if req.PlusOnes < 0 {
- writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
- return
- }
-
- guest, err := h.guests.Get(r.Context(), tk.GuestID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "failed to load guest")
+ guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
+ if !ok {
return
}
if req.PlusOnes > guest.PlusOnes {
@@ -95,13 +70,246 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
- event, err := h.events.Get(r.Context(), guest.EventID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "failed to load event")
+
+ decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
+ if !ok {
return
}
- fingerprint := mergeFingerprint(req.Fingerprint, collectFingerprint(r))
+ score := decision.Score
+ rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
+ GuestID: guest.ID,
+ Response: req.Resp,
+ PlusOnes: req.PlusOnes,
+ DietaryNotes: req.DietaryNotes,
+ DeviceFingerprint: fingerprint,
+ IPAddress: ip,
+ RiskScore: &score,
+ })
+ if err != nil {
+ if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
+ // Tier 2 Block A: guests edit an existing RSVP via PATCH. Tell
+ // the client to switch endpoints rather than swallowing the
+ // retry silently.
+ writeError(w, http.StatusConflict, "rsvp already submitted — use PATCH to edit")
+ return
+ }
+ h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
+ writeError(w, http.StatusInternalServerError, "failed to record rsvp")
+ return
+ }
+
+ // Block A: we intentionally do NOT mark the token used here any more.
+ // The token must remain valid so the guest can come back to the same
+ // link and edit their response.
+
+ h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score)
+
+ writeJSON(w, http.StatusCreated, submitRSVPResponse{
+ RSVP: rsvp,
+ Decision: decision,
+ Blocked: false,
+ Edited: false,
+ })
+}
+
+// PATCH /rsvp/{token} — revise an existing RSVP. Same fraud check as POST.
+// The prior state is snapshotted into rsvp_revisions inside Update; edits
+// past MaxRSVPEdits return 429.
+func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) {
+ tk, ok := h.loadValidToken(w, r)
+ if !ok {
+ return
+ }
+ req, ok := decodeSubmitRSVP(w, r)
+ if !ok {
+ return
+ }
+ guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
+ if !ok {
+ return
+ }
+ if req.PlusOnes > guest.PlusOnes {
+ writeError(w, http.StatusBadRequest,
+ fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
+ return
+ }
+
+ decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
+ if !ok {
+ return
+ }
+
+ score := decision.Score
+ rsvp, err := h.rsvps.Update(r.Context(), storage.UpdateRSVPParams{
+ GuestID: guest.ID,
+ Response: req.Resp,
+ PlusOnes: req.PlusOnes,
+ DietaryNotes: req.DietaryNotes,
+ DeviceFingerprint: fingerprint,
+ IPAddress: ip,
+ RiskScore: &score,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, domain.ErrRSVPNotFound):
+ writeError(w, http.StatusNotFound, "no rsvp to edit — submit first")
+ case errors.Is(err, domain.ErrRSVPEditLimitReached):
+ writeError(w, http.StatusTooManyRequests,
+ fmt.Sprintf("edit limit reached (%d edits)", domain.MaxRSVPEdits))
+ default:
+ h.logger.Error("update rsvp", "err", err, "guest_id", guest.ID)
+ writeError(w, http.StatusInternalServerError, "failed to update rsvp")
+ }
+ return
+ }
+
+ h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score)
+
+ writeJSON(w, http.StatusOK, submitRSVPResponse{
+ RSVP: rsvp,
+ Decision: decision,
+ Blocked: false,
+ Edited: true,
+ })
+}
+
+type rsvpHistoryResponse struct {
+ RSVP *domain.RSVP `json:"rsvp"`
+ Revisions []domain.RSVPRevision `json:"revisions"`
+}
+
+// GET /events/{id}/guests/{guest_id}/rsvp/history — host view of the
+// current RSVP plus every prior revision newest-first.
+func (h *rsvpHandler) history(w http.ResponseWriter, r *http.Request) {
+ hostID, ok := hostFromContext(w, r)
+ if !ok {
+ return
+ }
+ eventID, ok := parseIDParam(w, r, "id")
+ if !ok {
+ return
+ }
+ guestID, ok := parseIDParam(w, r, "guest_id")
+ if !ok {
+ return
+ }
+ if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
+ return
+ }
+
+ guest, err := h.guests.Get(r.Context(), guestID)
+ if err != nil {
+ if errors.Is(err, domain.ErrGuestNotFound) {
+ writeError(w, http.StatusNotFound, "guest not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to load guest")
+ return
+ }
+ if guest.EventID != eventID {
+ writeError(w, http.StatusNotFound, "guest not found in event")
+ return
+ }
+
+ rsvp, err := h.rsvps.GetByGuest(r.Context(), guestID)
+ if err != nil {
+ if errors.Is(err, domain.ErrRSVPNotFound) {
+ writeJSON(w, http.StatusOK, rsvpHistoryResponse{Revisions: []domain.RSVPRevision{}})
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to load rsvp")
+ return
+ }
+
+ revisions, err := h.rsvps.ListRevisions(r.Context(), rsvp.ID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to load revisions")
+ return
+ }
+ writeJSON(w, http.StatusOK, rsvpHistoryResponse{RSVP: rsvp, Revisions: revisions})
+}
+
+// --- shared helpers ---
+
+type decodedRSVPRequest struct {
+ Resp domain.RSVPResponse
+ PlusOnes int
+ DietaryNotes *string
+ Fingerprint map[string]any
+}
+
+func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) {
+ var req submitRSVPRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid json")
+ return decodedRSVPRequest{}, false
+ }
+ resp := domain.RSVPResponse(req.Response)
+ if !resp.Valid() {
+ writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
+ return decodedRSVPRequest{}, false
+ }
+ if req.PlusOnes < 0 {
+ writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
+ return decodedRSVPRequest{}, false
+ }
+ return decodedRSVPRequest{
+ Resp: resp,
+ PlusOnes: req.PlusOnes,
+ DietaryNotes: req.DietaryNotes,
+ Fingerprint: req.Fingerprint,
+ }, true
+}
+
+func (h *rsvpHandler) loadValidToken(w http.ResponseWriter, r *http.Request) (*domain.Token, bool) {
+ raw := r.PathValue("token")
+ if err := auth.ValidateFormat(raw); err != nil {
+ writeError(w, http.StatusBadRequest, "malformed token")
+ return nil, false
+ }
+ tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
+ if err != nil {
+ if errors.Is(err, domain.ErrTokenNotFound) {
+ writeError(w, http.StatusNotFound, "token not found")
+ return nil, false
+ }
+ writeError(w, http.StatusInternalServerError, "failed to load token")
+ return nil, false
+ }
+ if err := tk.IsValid(time.Now().UTC()); err != nil {
+ writeError(w, http.StatusGone, err.Error())
+ return nil, false
+ }
+ return tk, true
+}
+
+func (h *rsvpHandler) loadGuestEvent(w http.ResponseWriter, r *http.Request, guestID uuid.UUID) (*domain.Guest, *domain.Event, bool) {
+ guest, err := h.guests.Get(r.Context(), guestID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to load guest")
+ return nil, nil, false
+ }
+ event, err := h.events.Get(r.Context(), guest.EventID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to load event")
+ return nil, nil, false
+ }
+ return guest, event, true
+}
+
+// scoreAccess logs the access attempt, scores it, and short-circuits with a
+// 403 on a BLOCK decision. Returns the decision and the merged fingerprint /
+// IP so the caller can persist them on the RSVP row.
+func (h *rsvpHandler) scoreAccess(
+ w http.ResponseWriter,
+ r *http.Request,
+ event *domain.Event,
+ guest *domain.Guest,
+ tk *domain.Token,
+ clientFP map[string]any,
+) (fraud.Decision, map[string]any, string, bool) {
+ fingerprint := mergeFingerprint(clientFP, collectFingerprint(r))
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
@@ -124,39 +332,20 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
})
-
if fraud.IsBlock(decision) {
writeJSON(w, http.StatusForbidden, submitRSVPResponse{
Decision: decision,
Blocked: true,
})
+ return decision, nil, "", false
+ }
+ return decision, fingerprint, ip, true
+}
+
+func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *domain.RSVP, score *int) {
+ if h.pub == nil {
return
}
-
- score := decision.Score
- rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
- GuestID: guest.ID,
- Response: resp,
- PlusOnes: req.PlusOnes,
- DietaryNotes: req.DietaryNotes,
- DeviceFingerprint: fingerprint,
- IPAddress: ip,
- RiskScore: &score,
- })
- if err != nil {
- if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
- writeError(w, http.StatusConflict, "rsvp already submitted for this guest")
- return
- }
- h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
- writeError(w, http.StatusInternalServerError, "failed to record rsvp")
- return
- }
-
- if err := h.tokens.MarkUsed(r.Context(), tk.ID); err != nil {
- h.logger.Warn("mark token used", "err", err, "token_id", tk.ID)
- }
-
go func(evt natspub.RSVPConfirmed) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -164,20 +353,14 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID)
}
}(natspub.RSVPConfirmed{
- EventID: event.ID,
- GuestID: guest.ID,
+ EventID: eventID,
+ GuestID: guestID,
RSVPID: rsvp.ID,
Response: string(rsvp.Response),
PlusOnes: rsvp.PlusOnes,
- RiskScore: &score,
+ RiskScore: score,
SubmittedAt: rsvp.SubmittedAt,
})
-
- writeJSON(w, http.StatusCreated, submitRSVPResponse{
- RSVP: rsvp,
- Decision: decision,
- Blocked: false,
- })
}
func mergeFingerprint(client map[string]any, server map[string]any) map[string]any {
@@ -207,4 +390,3 @@ func stringifyFingerprint(fp map[string]any) map[string]string {
}
return out
}
-
diff --git a/internal/api/server.go b/internal/api/server.go
index 6b37e41..627419c 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -161,6 +161,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
events: eventRepo,
users: userRepo,
accessLogs: accessRepo,
+ rsvps: rsvpRepo,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
@@ -306,6 +307,16 @@ func (s *Server) Handler() http.Handler {
rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access)))
mux.Handle("POST /rsvp/{token}",
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
+ // Block A: edits are bounded by MaxRSVPEdits server-side. The redis
+ // limiter is a coarser guard that also throttles attempts that hit the
+ // edit-cap 429 path, so a hostile actor can't burn through fraud-engine
+ // calls on the same token.
+ mux.Handle("PATCH /rsvp/{token}",
+ rl("rsvp_edit", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.edit)))
+
+ // Host view of the edit trail for a single guest.
+ mux.Handle("GET /events/{id}/guests/{guest_id}/rsvp/history",
+ authed(http.HandlerFunc(s.rsvps.history)))
// WebSocket endpoint authenticates via single-use ticket on the query
// string (see POST /auth/ws-ticket).
diff --git a/internal/api/tokens.go b/internal/api/tokens.go
index 6e2134d..3e3bfba 100644
--- a/internal/api/tokens.go
+++ b/internal/api/tokens.go
@@ -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,
})
}
diff --git a/internal/domain/rsvp.go b/internal/domain/rsvp.go
index 9366f3a..e65e8c9 100644
--- a/internal/domain/rsvp.go
+++ b/internal/domain/rsvp.go
@@ -33,9 +33,29 @@ type RSVP struct {
DeviceFingerprint map[string]any `json:"device_fingerprint,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
RiskScore *int `json:"risk_score,omitempty"`
+ EditCount int `json:"edit_count"`
}
+// RSVPRevision is one snapshot of an RSVP's previous state, written before
+// the new values are applied on PATCH /rsvp/{token}. The host history view
+// renders these newest-first so the edit trail is auditable.
+type RSVPRevision struct {
+ ID uuid.UUID `json:"id"`
+ RSVPID uuid.UUID `json:"rsvp_id"`
+ PrevResponse RSVPResponse `json:"prev_response"`
+ PrevPlusOnes int `json:"prev_plus_ones"`
+ PrevDietary *string `json:"prev_dietary,omitempty"`
+ ChangedAt time.Time `json:"changed_at"`
+}
+
+// MaxRSVPEdits caps the number of times a single RSVP can be revised.
+// The 6th attempt returns 429 — keeps a hostile actor from churning the
+// host's activity feed with endless updates.
+const MaxRSVPEdits = 5
+
var (
ErrRSVPAlreadySubmitted = errors.New("rsvp already submitted")
ErrRSVPBlocked = errors.New("rsvp blocked due to fraud risk")
+ ErrRSVPNotFound = errors.New("rsvp not found")
+ ErrRSVPEditLimitReached = errors.New("rsvp edit limit reached")
)
diff --git a/internal/storage/migrations/0007_rsvp_edits.down.sql b/internal/storage/migrations/0007_rsvp_edits.down.sql
new file mode 100644
index 0000000..64f1a38
--- /dev/null
+++ b/internal/storage/migrations/0007_rsvp_edits.down.sql
@@ -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;
diff --git a/internal/storage/migrations/0007_rsvp_edits.up.sql b/internal/storage/migrations/0007_rsvp_edits.up.sql
new file mode 100644
index 0000000..1a34848
--- /dev/null
+++ b/internal/storage/migrations/0007_rsvp_edits.up.sql
@@ -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 0004–0006 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;
diff --git a/internal/storage/rsvps.go b/internal/storage/rsvps.go
index 2bd688f..62fb940 100644
--- a/internal/storage/rsvps.go
+++ b/internal/storage/rsvps.go
@@ -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
diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go
index 7fc0092..b8d2751 100644
--- a/test/integration/e2e_test.go
+++ b/test/integration/e2e_test.go
@@ -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