Skip to content

Fix waiting-list gaps in reject and destroy, pin promotion behaviour - #2996

Closed
mroderick wants to merge 4 commits into
codebar:masterfrom
mroderick:test/waiting-list-pre-existing-gaps
Closed

mroderick wants to merge 4 commits into
codebar:masterfrom
mroderick:test/waiting-list-pre-existing-gaps

Conversation

@mroderick

Copy link
Copy Markdown
Collaborator

Closes three pre-existing gaps in the workshop waiting-list flow and pins two previously unpinned behaviours. Work done from master (before the feat/post-close-rsvp-waitlist rename, so the tested entry point is WaitingList.next_spot; the branch's promote_next keeps this filtering).

Cancelling while waitlisted left a stale entry. WorkshopInvitationController#reject set attending: false but never destroyed the member's own WaitingList row. Worse, the promotion lookup could pick the cancelling member's own entry for the seat they had just freed: that entry was destroyed, the freshly cancelled invitation was set back to attending: true, and the member was emailed a confirmation they had already declined. The entry is now destroyed before the next spot is looked up, so promotion goes to the real next entry for the role.

Destroy of a gone entry 500'd. WaitingListsController#destroy called destroy on find_by(...), which is nil when the entry has already been consumed — reachable by replaying a stale "Remove from the waiting list" link. It now redirects with a notice instead.

Reject needed a session. reject is token-authenticated like accept and update, but was not in the skip_forgery_protection list, so a browser withholding the session cookie got InvalidAuthenticityToken. It is now skipped for the same reason the other token-authenticated actions are.

New pins: cross-role promotion (an older Coach entry is never picked when a Student seat frees up), the promotion email (delivery, its waiting-list body copy, and that exactly one email goes out), and the no-session token-only reject request.

Review notes

Suggested focus:

  • The reject fix (app/controllers/workshop_invitation_controller.rb): destroying the cancelling member's entry before computing the next spot is what prevents the self-promotion; the three new specs in its context pin entry removal, no re-acceptance, and promotion of the entry behind.
  • The CSRF skip for reject widens what a cross-site POST can invoke. The authenticator is the invitation token, which only email recipients hold; the reasoning mirrors accept, update and WaitingListsController (PR fix: skip CSRF protection for feedback form submission #2641, Rollbar Security warning in Firefox #535).
  • Deliberately left out, as branch-owned in feat/post-close-rsvp-waitlist: the closed-waitlist view guard, the destroy gate matrix (custom close vs 3.5h freeze), admin removal between freeze and start, promote_next update-failure path, concurrency, and the promotion/freeze boundary. The admin-removal email assertion also waits for that branch.

Add pins for two unpinned behaviours:

- WaitingList.next_spot filters by role, so an older Coach entry must not
  be returned when a Student seat frees up (model) and must not be
  promoted when a Student cancels (controller).
- The reject flow sends the promoted member the waiting-list variant of
  the attending email; assert the delivery, the promoted copy's text,
  and that exactly one email goes out.
WorkshopInvitationController#reject set `attending: false` but left the
member's WaitingList row in place, so a cancelling member's own entry
could be returned by `WaitingList.next_spot` for the seat they just
freed: the flow destroyed that entry, re-set the freshly cancelled
invitation to attending and emailed the member they are attending again.

Destroy the cancelling member's entry before looking up the next spot,
so a rejection cannot list the member back in and the promotion goes to
the real next entry for the role.
The reject endpoint authenticates by the invitation token in the URL,
like accept and update. Its rejection form must survive a browser
withholding the session cookie (e.g. Safari/WebKit ITP on cross-site
navigation), so CSRF enforcement raises InvalidAuthenticityToken before
the token can do its job. Add reject to the skip_forgery_protection
list, matching accept, update and WaitingListsController, and pin the
token-only, no-session request with a spec.
WaitingListsController#destroy called destroy on
WaitingList.find_by(...), which is nil when the entry no longer exists —
reachable by replaying a stale "Remove from the waiting list" link
(double-click, or a bookmarked token URL after the entry was consumed),
a 500 that Rollbar hears. Guard the lookup and redirect with a notice
when the entry is gone, and skip the waiting_list.left activity record.
@mroderick

Copy link
Copy Markdown
Collaborator Author

Split into one PR per gap so each can be reviewed separately: #2997, #2998, #2999, #3000, #3001. Same changes (combined tree verified green), different branches.

@mroderick mroderick closed this Oct 9, 2026
@mroderick
mroderick deleted the test/waiting-list-pre-existing-gaps branch October 9, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant