← All stories

BRANCH · ef-018-invite-to-rsvp

Invite to RSVP

EF-018 Personas: Organizer + Invited guest Stage: Setup → Public Roots in: event-setup + public-event-page (planned) EF reference: EventFarm doc

A private, free, invite-only event. The organizer creates an Invite to RSVP access type, picks who's invited, and sends the invitations. Each invited guest gets a personal link that lets them RSVP yes or no — and only invited guests can register, full stop. The most product-defining failure mode is what an uninvited guest sees when they try to access the event: not a hostile 404, not a leak, but a clear, dignified "you aren't on the list" with an obvious path to request access.

Happy path

Path A — Organizer creates the invite-only access type and sends invitations

  1. From the event-setup hub, click into Access types and create a new one.

    Choose distribution: Invite to RSVP. Capacity, transferability, FCFS settings appear with sensible defaults. Plus-ones are off by default — the organizer can enable per-invitation later.

  2. Save. Return to setup hub. The access types card status pill updates.

    Hub's checklist now suggests Invite guests as the next action.

  3. Click into Audience. Choose to invite individuals or import a list.

    For individuals: pick from address book autocomplete (uses ui-autocomplete against contact records) OR add new ones inline. For import: CSV upload (ui-file-upload) with column-mapping preview before commit.

  4. Send invitations.

    For each invited guest, the system creates a row in event_guests with access_type=invited, mints an invite token (HMAC-signed), and queues an invitation email (an EF-052 association-driven send for the invitation message-type). The send dialog shows progress: "Sent 24 of 50…". Failures (bounces, invalid emails) are surfaced per-guest, not silently dropped.

Path B — Invited guest receives email and RSVPs

  1. Guest receives invitation email.

    Email is sent via Cloudflare Email Sending using the configured invitation design (EF-051). Subject and body are templated with merge tokens (EF-056). The invite link points to /p/<event-slug>/rsvp?token=<invite-token>.

  2. Guest clicks the link.

    Lands on the public RSVP page. Server validates the token: signature, expiry, guest binding. On success, the page shows the event's name, date, location, AND the guest's name pre-filled (so they know the invite was personal). The RSVP form has two primary actions: Accept and Decline, plus an optional message field.

  3. Guest clicks Accept.

    A confirmation modal appears (uses ui-modal) restating the event details. Guest confirms. Server transitions the event_guests row to status=confirmed. The page updates: confirmation card shows "You're confirmed for <Event name>" with calendar links (EF-058) and a copy of the invite they can save.

  4. Guest receives confirmation email automatically (EF-052 confirmation association).

    QR code (EF-057) embedded for day-of check-in. Calendar links work in Google / Outlook / Yahoo / Apple (EF-058).

Failure modes

The rejection — uninvited guest tries to access the RSVP page

Trigger: someone forwards their invite link to a friend, OR a bot probes /p/<event-slug>/rsvp?token=... with a guessed token, OR the link gets posted publicly. The token doesn't validate (signature mismatch, no matching guest, or guest is on a different event).

The dignified-rejection contract. The page does not 404. It does not show a hostile error. It shows a clear, courteous explanation:

  • Page H1: "This invitation isn't valid for this account" (or "This event is invitation-only" if the token is structurally absent).
  • One sentence of context: "<Event name> is a private event. Invitations are personal and can't be shared."
  • If the event has a public-facing description (organizer enabled "show event title to non-invitees"): the title and date are shown so the recipient can verify they were trying to access the right event. If not: no leak — just the message.
  • A primary action: Request an invitation. Clicking opens a form (uses ui-form) asking for the requester's email and an optional message. Submission goes to the organizer for approval, NOT to the public page.
  • A secondary action: Already invited? Try a different link — leads to a help page explaining the invite link is personal and what to do if they've lost theirs.
  • NO retry button, NO error code visible to the user, NO mention of why specifically the token failed (signature vs expiry vs unknown guest — that's diagnostic info that helps probing).

The tone matters as much as the mechanics. The page is warm, not hostile. The user did nothing wrong; they're just not on the list. Visual style is muted, not red-alert. Color contrast still meets WCAG-AA throughout.

Recovery: Request access via the form. If the organizer has the request, they can issue a new invitation through the audience tools.

Expired invite

Trigger: the event has passed, OR the organizer set an invitation expiry that has elapsed.

The page shows a different message: "This invitation has expired." The event title and date ARE shown (since the recipient was confirmed-invited; we already knew them). If the event has passed, the page offers to add a follow-up email subscription if the organizer enabled future-events-list. If only the invite expired but the event is upcoming, the primary action is Request a new link — sends a request to the organizer to re-issue.

Recovery: Request a new link; organizer can reissue or update the expiry.

Already accepted — re-click invite link

Trigger: guest accepted yesterday, clicks the invite link again today.

The page recognizes the token + the existing status=confirmed row. Shows the confirmation card directly: "You're confirmed for <Event name>" with calendar links, QR, and event details. Below: an option to Change my response if the event still allows it (organizer config). Re-clicking does NOT re-accept (no double-acceptance).

Recovery: No action needed. The guest is reassured they're confirmed.

Already declined — re-click invite link

Trigger: guest declined last week, changes their mind, clicks the invite link.

The page recognizes the existing status=declined row. Shows: "You declined <Event name> on <date>. Would you like to update your response?" If the event allows status changes (organizer config; default yes until N days before event): the Accept button is offered. If declined-locked: a message explains why ("Responses can no longer be changed; contact the organizer if needed").

Recovery: Update response, or contact organizer.

Capacity full when guest tries to accept (FCFS race)

Trigger: organizer sent invitations for an FCFS access type with capacity = 50. The 51st guest tries to accept; the slot is gone.

The page shows: "<Event name> is now at capacity. We've added you to the waitlist — you'll be notified if a spot opens." The system creates a status=waitlisted row. The guest does NOT see a hostile error or "you missed it." Waitlist position is visible (guest is #3 on the list), so they have realistic information about likelihood.

Recovery: Waitlist; auto-promotion if a confirmed guest cancels (EF-025).

Token tampering — signature invalid

Trigger: someone modifies the token query parameter (changing one character, etc.).

Server's HMAC verification fails. The page shows the same "This invitation isn't valid" message as the rejection failure mode — does NOT distinguish a tampered token from an unknown one. Distinguishing would help an attacker probing for valid token formats. Logged server-side at higher severity than a normal rejection so security can flag suspicious patterns.

Recovery: Same as the rejection failure mode — Request an invitation.

Guest tries someone else's invite link

Trigger: Alice forwards her invite link to Bob. Bob clicks; the page loads with Alice's name pre-filled in the RSVP form.

This is technically the happy path from a security perspective — the token is valid, the guest record is for Alice, the page renders Alice's RSVP form. Bob sees Alice's name in the field. The page does NOT auto-send an "Alice accepted" if Bob clicks Accept — but it WILL register Alice, not Bob. This is a forwarding-detection moment, not a rejection.

To handle Bob: the page includes a small "Not Alice? Request your own invitation" affordance. Clicking takes Bob through the request-invitation flow. The page does NOT prompt Bob to enter his email "instead of Alice" — that would let him trivially accept-on-behalf-of-Alice. The invitation belongs to Alice and only Alice.

Recovery: Bob requests his own. Alice's invite is unaffected.

Invitation email bounced — guest never knew they were invited

Trigger: invitation email bounces (full mailbox, dead address, spam-blocked). The organizer's deliverability report (EF-089) shows the bounce.

This isn't a guest-side failure mode — it's an organizer-side observability one. The audience view shows guests with their last-email-status pill: delivered, opened, clicked, bounced, declined. Bounced guests are surfaced with a "Resend invitation" affordance and an option to update their email address. The guest who didn't receive isn't blamed for not RSVPing.

Recovery: Organizer updates the address and resends, OR removes the guest from the audience.

Network failure during RSVP submit

Trigger: guest clicks Accept; their connection drops; the request fails.

The form (uses ui-form) preserves their selection. Error banner above the form: "Couldn't save your response. Check your connection and try again." Retry sends the same request with the same Idempotency-Key so the server dedupes if the original landed but the response was lost. Guest doesn't accidentally double-confirm or end up in an inconsistent state.

Recovery: Retry. Idempotent.

Plus-one denied for an access type that doesn't allow them

Trigger: invitation didn't grant plus-ones, but the guest tries to add a +1 in the form.

The plus-one field is hidden when not allowed — not shown disabled. (Showing-disabled invites the question "why can't I?" which we can't always answer well.) The guest only sees fields that apply to their invite. If the organizer DID grant +1: the field is visible, with a count limit if applicable, and the +1's name + email are required.

Recovery: N/A — the +1 affordance simply isn't shown when not allowed.

Organizer revokes an invitation while guest is mid-RSVP

Trigger: guest opened the RSVP page 5 minutes ago. While they were composing their response, organizer revoked the invitation.

Guest clicks Accept. Server returns 410 Gone with code INVITATION_REVOKED. The page transitions to the rejection state with a softened message: "Your invitation was withdrawn before you could respond. If you think this was a mistake, please contact the organizer." Their composed response is shown back to them ("You were going to say: '<message>'") so they don't lose what they wrote — they can copy it for an email to the organizer.

Recovery: Out-of-band — contact the organizer.

Event was archived between invite-send and click

Trigger: organizer archived the event. Guest clicks invite link a day later.

Page shows "<Event name> is no longer available." with the event date. If a follow-up email subscription was enabled, an option to subscribe. No "request invitation" affordance — the event is gone, can't reissue.

Recovery: Subscribe to future events, or move on.

Edge cases

Guest's email address has changed since invitation was sent

Token validates against the guest record, not the email at click-time. If the organizer updated Alice's email in the audience after sending, the original token still works — it's bound to Alice's guest_id, not her email.

Guest declines, then organizer re-invites them after deletion

Re-invitation creates a new guest row with a new token. The old declined-row is preserved in audit. Guest's response history is visible to the organizer (guest declined version 1, was re-invited and accepted version 2).

Multi-language event

If event has translated content, the RSVP page renders in the language inferred from the guest's record (organizer can set per-guest), with a language switcher. Rejection / expired / capacity-full messages all translate.

Mobile viewport (375×667)

RSVP page is single-column. Accept / Decline buttons are full-width and stack. Confirmation modal is a full-screen sheet (per ui-modal mobile contract). Calendar buttons stack vertically; QR is rendered at 200×200 minimum so it's scannable.

Print or save-as-PDF the RSVP confirmation page

Print stylesheet hides the navigation chrome and prints the event card with the QR. Useful when guests want to print a hard copy of their confirmation for their travel plans.

Page evaluation

SurfaceDiscoverabilityError UXLayoutOrientation
Organizer · Access types create Distribution chooser (Invite to RSVP / Public Registration / Public Purchase / Invite to Purchase) is the first visible field. Inline errors per field; no toast. Single-column form; submit sticky-bottom on mobile. Page H1 includes the event name + "New access type."
Organizer · Audience invitations Invite-list editor uses ui-autocomplete for contact picker. Send button is visible without scrolling once at least one guest is added. Per-guest validation surfaces invalid emails inline. Bulk paste of mixed valid/invalid splits and shows both groups. Two-pane: list of selected guests on left, send config on right at ≥1024px. Send progress modal indicates count and percent complete.
Guest · RSVP page (valid invite) Accept and Decline are equal-prominence buttons, side-by-side at ≥640px wide. Optional message field is below, not blocking. Network errors show inline; idempotent retry. Single-column, max-width 640px centered. Event header card is sticky on scroll. Page H1 is "<Event name>" — not "RSVP." Guest's name pre-filled and visible.
Guest · Rejection page Request-an-invitation CTA is the primary visible action. The "already invited?" link is secondary. The page IS itself an error UX. No nested error states. Tone is warm, not hostile. Single-column, max-width 640px centered. No event branding if organizer disabled "show event title to non-invitees." H1 is "This invitation isn't valid for this account" — not "Error 401."
Guest · Confirmation page (post-accept) Calendar buttons (Google / Outlook / Yahoo / Apple) are equal-prominence and visible without scrolling. If a calendar provider's link fails for some reason, the others still work. Confirmation card; QR below; calendar buttons row; "Add to wallet" affordance if applicable. H1 is "You're confirmed for <Event name>." Past tense; communicates completion.

Acceptance signals

  • Organizer-side: POST /v1/admin/events/:id/access-types with { kind: "invite_to_rsvp" } returns 201 and the access type row is in event_access_types.
  • Organizer-side: POST /v1/admin/events/:id/guests/invite with N guest rows returns 200 and N notification_outbox rows are queued (per EF-052 association-driven send).
  • Guest-side, valid token: GET /p/:slug/rsvp?token=... returns 200 and the page H1 contains the event's name AND the guest's name is in the form pre-fill.
  • Guest-side, accept: POST /v1/public/events/:id/rsvp with valid token returns 200 and event_guests.status for that guest_id transitions to confirmed.
  • Guest-side, invalid token: GET returns 200 (not 404, not 401) AND the page contains "isn't valid" or "invitation-only" copy AND the rejection page's data-test attributes are visible.
  • The rejection page does NOT leak the event title or date in the rendered HTML when "show event title to non-invitees" is disabled.
  • document.title for the rejection page is generic ("Invitation not valid · Voyage") — does NOT contain the event name.
  • No console errors at severity ≥ warn.

Stable test attributes

This branch's contract — the named data-test attributes the Aperture booking-pages and admin code MUST expose. Branch-specific only; trunk and component attributes are inherited and not re-listed.

Visibility teeth. Each attribute must be present AND effectively visible when its state is active. Hiding without removal is a Ratchet violation.

data-testWherePurpose
access-type-distribution-chooserAdmin · access types create formThe 4-option distribution chooser
access-type-distribution-invite-to-rsvpInside the chooserThe Invite to RSVP option
audience-invite-listAdmin · audience pageThe list of selected guests to invite
audience-invite-send-ctaAdmin · audience pageSend invitations button
audience-send-progressAdmin · send progress modalProgress UI during bulk send
audience-guest-status-pillAdmin · audience page (per-row)delivered / opened / clicked / bounced / accepted / declined
audience-bounced-resend-ctaAdmin · audience row in bounced stateResend invitation affordance
rsvp-pageGuest · public RSVP page (valid token)Root container of the RSVP form
rsvp-event-titleInside rsvp-pageEvent name as page H1
rsvp-guest-name-prefillInside rsvp-pagePre-filled guest name (read-only)
rsvp-accept-ctaInside rsvp-pageAccept button
rsvp-decline-ctaInside rsvp-pageDecline button
rsvp-message-fieldInside rsvp-pageOptional message textarea
rsvp-confirm-modalModal portal (uses ui-modal)Accept-confirmation modal
rsvp-confirmation-pageGuest · post-accept page"You're confirmed" page
rsvp-confirmation-h1Inside confirmation pagePast-tense H1 with event name
rsvp-qr-codeInside confirmation pageQR for day-of check-in (uses EF-057 contract)
rsvp-calendar-googleInside confirmation pageGoogle Calendar add link (uses EF-058)
rsvp-calendar-outlookInside confirmation pageOutlook calendar link
rsvp-calendar-yahooInside confirmation pageYahoo calendar link
rsvp-calendar-icsInside confirmation pageICS download link
rejection-pageGuest · invalid-token pageThe dignified-rejection page; root container
rejection-h1Inside rejection-page"This invitation isn't valid for this account" or equivalent
rejection-contextInside rejection-pageOne-sentence courteous explanation
rejection-event-title-optionalInside rejection-pageEvent title — present ONLY if organizer enabled "show event title to non-invitees"
rejection-request-invite-ctaInside rejection-pagePrimary action — opens the request-invitation form
rejection-already-invited-helpInside rejection-pageSecondary action — link to "lost your invite?" help
request-invitation-formInside rejection-page after CTA clickForm to request access (uses ui-form, ui-text-input)
request-invitation-successAfter form submit"Your request was sent" confirmation
expired-invite-pageGuest · expired-token page"This invitation has expired" variant
expired-invite-request-new-ctaInside expired-invite-pageRequest a new link
capacity-full-pageGuest · capacity-full state"At capacity — added to waitlist"
capacity-full-waitlist-positionInside capacity-full-pageWaitlist position number
already-confirmed-pageGuest · re-click after acceptExisting confirmation, optionally with change-response CTA
already-declined-pageGuest · re-click after declineDecline confirmation; optional update-response CTA if allowed
change-response-ctaInside already-confirmed or already-declined pageReopens the form to update the response
archived-event-rsvp-pageGuest · event archived after invite sent"Event no longer available" page
revoked-invitation-pageGuest · invitation revoked mid-flow"Your invitation was withdrawn" with composed-message preserved

Agent test plan

Inherits trunk preconditions from event-setup (which itself inherits admin-shell-access). Public-event-page trunk is planned; until written, public-side probes navigate directly to /p/:slug/rsvp?token=... URLs against fixture events.

Probe list — happy path A (organizer)
- create-invite-rsvp-access-type: from event-setup hub, click access types CTA, pick Invite to RSVP, submit, assert access_type row created
- audience-pick-and-send: navigate to audience, add 3 guests via autocomplete, click send, assert progress visible AND notification_outbox has 3 queued rows
- audience-shows-bounced-pill: stub one of the queued rows to bounce, assert audience-guest-status-pill for that row reads "bounced" AND audience-bounced-resend-cta visible
Probe list — happy path B (guest)
- valid-token-shows-rsvp: navigate to /p/:slug/rsvp?token=${fixture.validToken}, assert rsvp-page visible, rsvp-event-title contains event name, rsvp-guest-name-prefill matches guest's name
- accept-confirms: click rsvp-accept-cta, confirm in modal, assert API POST returns 200 AND URL transitions to /p/:slug/rsvp/confirmed AND rsvp-confirmation-page visible
- confirmation-shows-calendar-and-qr: assert rsvp-calendar-google + outlook + yahoo + ics all visible AND rsvp-qr-code visible
Probe list — failure modes (the centerpiece)
- rejection-page-renders: navigate with token=invalid, assert rejection-page visible AND rejection-h1 matches /isn't valid|invitation-only/ AND rejection-request-invite-cta visible
- rejection-no-event-leak: with "show event title to non-invitees" disabled, assert rejection-event-title-optional ABSENT AND document.title does not contain event name AND HTML body does not contain event name in any form
- rejection-event-leak-when-enabled: with the org setting enabled, assert rejection-event-title-optional visible
- rejection-tone-not-hostile: assert no element contains text matching /(?i)error|denied|forbidden|unauthorized|401|403|404/
- rejection-no-diagnostic-leak: with token=structurally-malformed and token=signature-tampered, assert the rendered HTML is byte-identical (no distinguishing diagnostic information)
- request-invitation-flow: click rejection-request-invite-cta, fill request-invitation-form, submit, assert request-invitation-success visible
- expired-invite: navigate with token=expired, assert expired-invite-page visible AND expired-invite-request-new-cta present
- already-accepted-reclick: with token whose guest is already confirmed, navigate, assert already-confirmed-page visible AND change-response-cta visible (if event allows changes)
- already-declined-reclick: similar with declined guest
- capacity-full: stub event capacity exhausted, click accept, assert capacity-full-page visible AND capacity-full-waitlist-position has a number
- token-tampered-same-as-rejection: navigate with tampered token, assert page is rendering-equivalent to navigate-with-invalid-token (anti-probing)
- network-fail-rsvp: stub POST /v1/public/events/:id/rsvp to network-fail, click accept, assert form-error-banner visible AND idempotency-key sent on retry
- archived-event: navigate after archiving, assert archived-event-rsvp-page visible
- revoked-mid-flow: open RSVP page, fill message, stub POST to 410 INVITATION_REVOKED, click accept, assert revoked-invitation-page visible AND composed message preserved