From 4bba34585c923562234c84d8ac525090a4dc82a9 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 9 Oct 2026 10:47:09 +0200 Subject: [PATCH 1/4] feat(rsvp): add freeze and waitlist close gates Adds the two time gates from issue #2990. waitlist_closes_at is the earlier of the custom RSVP close time and the freeze, so new joins stop at the configured close. cancellations_open? tracks the freeze alone, so a member may cancel after the close and until the freeze. The freeze is the hard cutoff 3.5 hours before the start, requirement 4 of the issue, and rsvp_freezes_at owns that offset alone. The RSVP flows that apply these gates follow in the next commit. --- app/models/concerns/rsvp_closable.rb | 9 +++- app/models/workshop.rb | 15 +++++++ spec/models/workshop_spec.rb | 42 +++++++++++++++++++ .../behaves_like_rsvp_closable.rb | 8 ++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/rsvp_closable.rb b/app/models/concerns/rsvp_closable.rb index ad1bb3a33..317faca45 100644 --- a/app/models/concerns/rsvp_closable.rb +++ b/app/models/concerns/rsvp_closable.rb @@ -11,11 +11,18 @@ module InstanceMethods # The time after which new RSVPs are no longer accepted. Models with an # explicit close time override this to prefer the stored value. def effective_rsvp_closes_at - date_and_time - DEFAULT_RSVPS_CLOSE_OFFSET + rsvp_freezes_at end def rsvp_available? effective_rsvp_closes_at.future? end + + # The instant when member RSVP changes freeze, regardless of a custom + # close time. This method alone owns the default offset; actions are + # blocked at exactly this moment, not after it. + def rsvp_freezes_at + date_and_time - DEFAULT_RSVPS_CLOSE_OFFSET + end end end diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 15f9dcfeb..370159504 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -136,6 +136,21 @@ def effective_rsvp_closes_at rsvp_closes_at || super end + # The last moment a member may join or leave the waiting list. The waitlist + # goes to security at the RSVP close time, so both lists must be stable + # after that point, and member actions freeze 3.5 hours before the start. + def waitlist_closes_at + [rsvp_closes_at, rsvp_freezes_at].compact.min + end + + def waitlist_open? + waitlist_closes_at > Time.zone.now + end + + def cancellations_open? + rsvp_freezes_at > Time.zone.now + end + private def set_opens_at diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index 0a010668c..c5078d522 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -373,4 +373,46 @@ expect(codes.uniq.length).to eq(3) end end + + describe '#waitlist_closes_at' do + it 'is the earlier of the custom close time and the freeze' do + workshop = Fabricate.build(:workshop, date_and_time: 4.hours.from_now, rsvp_closes_at: 3.6.hours.from_now) + + expect(workshop.waitlist_closes_at).to eq(workshop.rsvp_freezes_at) + end + + it 'is the custom close time when it is before the freeze' do + workshop = Fabricate.build(:workshop, date_and_time: 4.hours.from_now, rsvp_closes_at: 20.minutes.from_now) + + expect(workshop.waitlist_closes_at).to eq(workshop.rsvp_closes_at) + end + end + + describe '#waitlist_open?' do + it 'is open more than 3.5 hours before the start with no custom close time' do + workshop = Fabricate.build(:workshop, date_and_time: 2.days.from_now, rsvp_closes_at: nil) + + expect(workshop.waitlist_open?).to be(true) + end + + it 'is closed at exactly the freeze time' do + workshop = Fabricate.build(:workshop, date_and_time: 3.5.hours.from_now, rsvp_closes_at: nil) + + expect(workshop.waitlist_open?).to be(false) + end + end + + describe '#cancellations_open?' do + it 'stays open after a custom close time that falls before the freeze' do + workshop = Fabricate.build(:workshop, date_and_time: 4.hours.from_now, rsvp_closes_at: 3.hours.ago) + + expect(workshop.cancellations_open?).to be(true) + end + + it 'closes at exactly the freeze time' do + workshop = Fabricate.build(:workshop, date_and_time: 3.5.hours.from_now, rsvp_closes_at: 1.hour.from_now) + + expect(workshop.cancellations_open?).to be(false) + end + end end diff --git a/spec/support/shared_examples/behaves_like_rsvp_closable.rb b/spec/support/shared_examples/behaves_like_rsvp_closable.rb index 670bc36ba..920f7e2cc 100644 --- a/spec/support/shared_examples/behaves_like_rsvp_closable.rb +++ b/spec/support/shared_examples/behaves_like_rsvp_closable.rb @@ -38,4 +38,12 @@ expect(subject.rsvp_available?).to be(false) end end + + describe '#rsvp_freezes_at' do + it 'is 3.5 hours before the start' do + subject.date_and_time = Time.zone.local(2026, 3, 1, 18, 30) + + expect(subject.rsvp_freezes_at).to eq(Time.zone.local(2026, 3, 1, 15, 0)) + end + end end From 6208d1f405b65be06fec84b022c4e4a1c31e6038 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 9 Oct 2026 10:56:03 +0200 Subject: [PATCH 2/4] feat(waitlist): add locked FIFO promotion and atomic seat release A cancel frees a seat only when the invitation held one, so rejecting an invitation that never answered must not consume a waiting-list entry. release_seat claims the seat with a row-locked read-modify-write, so duplicate concurrent cancels free it once. promote_next pops the head in a transaction with FOR UPDATE SKIP LOCKED so concurrent promoters take different entries, and confirms with update! so a failed confirmation rolls the pop back instead of emailing a spot that was never granted. The FIFO order and the seat-freed rule are pinned by specs. The locking behaviour cannot be exercised by the single-threaded suite, so this message is where it is recorded. WaitingList.next_spot still serves the current RSVP flows until the next commit replaces it. --- .../concerns/waitlist_promotion_concerns.rb | 30 +++++++++++ app/models/waiting_list.rb | 17 ++++++ spec/models/waiting_list_spec.rb | 52 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 app/controllers/concerns/waitlist_promotion_concerns.rb diff --git a/app/controllers/concerns/waitlist_promotion_concerns.rb b/app/controllers/concerns/waitlist_promotion_concerns.rb new file mode 100644 index 000000000..80e23955f --- /dev/null +++ b/app/controllers/concerns/waitlist_promotion_concerns.rb @@ -0,0 +1,30 @@ +# Shared by controllers that cancel a workshop invitation and fill the freed +# seat from the waiting list. Requires @invitation and the decorated +# @workshop presenter to be set by the including controller. +module WaitlistPromotionConcerns + extend ActiveSupport::Concern + + included do + include InstanceMethods + end + + module InstanceMethods + private + + # Releases the invitation's seat under a row lock so two concurrent + # cancellation requests cannot each believe they freed a seat. Returns true + # only when this request moved a seat-holder to not attending. + def release_seat(additional_attributes = {}) + @invitation.with_lock do + was_attending = @invitation.attending.eql?(true) + @invitation.update!(additional_attributes.merge(attending: false)) + was_attending + end + end + + def promote_next_waitlist_member + promoted = WaitingList.promote_next(@invitation.workshop, @invitation.role) + @workshop.send_attending_email(promoted, true) if promoted + end + end +end diff --git a/app/models/waiting_list.rb b/app/models/waiting_list.rb index d0affcfd8..0d21d7941 100644 --- a/app/models/waiting_list.rb +++ b/app/models/waiting_list.rb @@ -26,6 +26,23 @@ def self.next_spot(workshop, role) by_workshop(workshop).where_role(role).where(auto_rsvp: true).first end + # Pops the next auto-RSVP waitlist entry and confirms its invitation. The + # caller sends the attendance email for the promoted invitation. + def self.promote_next(workshop, role) + transaction do + # SKIP LOCKED lets a concurrent promoter that holds another freed seat + # take the next entry instead of racing on this one. + next_spot = by_workshop(workshop).where_role(role).where(auto_rsvp: true) + .order(:created_at).lock('FOR UPDATE SKIP LOCKED').first + return unless next_spot + + invitation = next_spot.invitation + next_spot.destroy + invitation.update!(attending: true, rsvp_time: Time.zone.now, automated_rsvp: true) + invitation + end + end + def self.coaches_for(workshop) by_workshop(workshop).where_role('Coach').order(:created_at) end diff --git a/spec/models/waiting_list_spec.rb b/spec/models/waiting_list_spec.rb index 051c27ddd..a1ef9fafe 100644 --- a/spec/models/waiting_list_spec.rb +++ b/spec/models/waiting_list_spec.rb @@ -34,6 +34,58 @@ expect(described_class.by_workshop(workshop).count).to eq(1) end end + + describe '#promote_next' do + it 'confirms the next auto-RSVP invitation and removes its waitlist entry' do + invitation = Fabricate(:workshop_invitation, workshop:) + described_class.add(invitation) + + promoted = described_class.promote_next(workshop, 'Student') + + expect(promoted).to eq(invitation) + expect(invitation.reload.attending).to be(true) + expect(invitation.automated_rsvp).to be(true) + expect(described_class.by_workshop(workshop)).to be_empty + end + + it 'promotes the FIFO head - the entry with the earliest created_at' do + later_invitation = Fabricate(:workshop_invitation, workshop:) + earlier_invitation = Fabricate(:workshop_invitation, workshop:) + described_class.add(later_invitation) + described_class.add(earlier_invitation).update!(created_at: 1.hour.ago) + + promoted = described_class.promote_next(workshop, 'Student') + + expect(promoted).to eq(earlier_invitation) + expect(earlier_invitation.reload.attending).to be(true) + expect(later_invitation.reload.attending).to be_nil + expect(described_class.by_workshop(workshop).map(&:invitation)).to eq([later_invitation]) + end + + it 'returns nil and promotes nothing for an empty waitlist' do + expect(described_class.promote_next(workshop, 'Student')).to be_nil + end + + it 'does not promote entries without auto_rsvp' do + invitation = Fabricate(:workshop_invitation, workshop:) + described_class.add(invitation) + + waiting = described_class.by_workshop(workshop).first + waiting.update!(auto_rsvp: false) + + expect(described_class.promote_next(workshop, 'Student')).to be_nil + expect(invitation.reload.attending).to be_nil + end + + it 'ignores an older entry for another role' do + coach_invitation = Fabricate(:coach_workshop_invitation, workshop:, member: Fabricate(:coach)) + described_class.add(coach_invitation) + + expect(described_class.promote_next(workshop, 'Student')).to be_nil + expect(coach_invitation.reload.attending).to be_nil + expect(described_class.by_workshop(workshop).count).to eq(1) + end + end end describe '#add' do From 0d1a07189a1902d036cec850ae71706eeedc64fe Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 9 Oct 2026 11:02:58 +0200 Subject: [PATCH 3/4] feat(rsvp): allow post-close cancellation with promotion until the freeze Once the custom RSVP close time passes, new joins to the main list and the waiting list stay blocked, while main-list members may cancel until the freeze. Each cancel that frees a seat promotes the first auto-RSVP waiting-list entry in created order. Admin removals use the same promotion path while the workshop is in the future. This commit replaces WaitingList.next_spot in the RSVP flows and removes it, now that promote_next covers it. The end-to-end pins cover the reject path, the admin removal path, and the waitlist join and leave gates. --- .../admin/invitations_controller.rb | 5 +- app/controllers/waiting_lists_controller.rb | 10 +++ .../workshop_invitation_controller.rb | 16 +--- app/models/waiting_list.rb | 4 - .../_waiting_list.html.haml | 23 ++--- app/views/workshop_invitation/show.html.haml | 2 +- config/locales/en.yml | 2 + .../admin/invitations_controller_spec.rb | 45 ++++++++++ .../waiting_lists_controller_spec.rb | 83 +++++++++++++++++ .../workshop_invitation_controller_spec.rb | 90 +++++++++++++++---- spec/models/waiting_list_spec.rb | 18 ---- .../behaves_like_an_invitation_route.rb | 26 +++++- 12 files changed, 260 insertions(+), 64 deletions(-) diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index bd7076784..0b3d1a343 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -1,5 +1,6 @@ class Admin::InvitationsController < Admin::ApplicationController include Admin::WorkshopConcerns + include WaitlistPromotionConcerns def update set_and_decorate_workshop @@ -80,10 +81,12 @@ def attending_failed end def update_to_not_attending - @invitation.update!(attending: false, last_overridden_by_id: current_user.id) + freed_seat = release_seat(last_overridden_by_id: current_user.id) MemberActivityRecorder.record(actor: current_user, key: 'invitation.rsvp_override', trackable: @invitation, recipient: @invitation.member) + promote_next_waitlist_member if freed_seat && @workshop.future? + { message: "You have removed #{@invitation.member.full_name} from the workshop.", error: false diff --git a/app/controllers/waiting_lists_controller.rb b/app/controllers/waiting_lists_controller.rb index 471def73d..f55404040 100644 --- a/app/controllers/waiting_lists_controller.rb +++ b/app/controllers/waiting_lists_controller.rb @@ -8,6 +8,10 @@ class WaitingListsController < ApplicationController skip_forgery_protection only: %i[create destroy] def create # rubocop:disable Metrics/MethodLength + # Joins and leaves stop at the earlier of the RSVP close time and the + # 3.5-hour freeze, so the waitlist handed to security stays stable. + return back_with_message(t('messages.waiting_list.closed')) if waitlist_closed? + @invitation.assign_attributes(invitation_params) return back_with_message(@invitation.errors.full_messages) unless @invitation.valid?(:waitinglist) @@ -26,6 +30,8 @@ def create # rubocop:disable Metrics/MethodLength end def destroy + return back_with_message(t('messages.waiting_list.closed')) if waitlist_closed? + WaitingList.find_by(invitation_id: @invitation.id).destroy MemberActivityRecorder.record(actor: @invitation.member, key: 'waiting_list.left', trackable: @invitation) @@ -35,6 +41,10 @@ def destroy private + def waitlist_closed? + !@invitation.workshop.waitlist_open? + end + def token params.permit(:invitation_id)[:invitation_id] end diff --git a/app/controllers/workshop_invitation_controller.rb b/app/controllers/workshop_invitation_controller.rb index 9fb6c9bc9..7008fdfc1 100644 --- a/app/controllers/workshop_invitation_controller.rb +++ b/app/controllers/workshop_invitation_controller.rb @@ -1,5 +1,6 @@ class WorkshopInvitationController < ApplicationController include WorkshopInvitationConcerns + include WaitlistPromotionConcerns # NOTE: This controller handles workshop invitations (WorkshopInvitation model). # It provides accept/reject RSVP actions for workshop attendees via token-based links. @@ -57,25 +58,16 @@ def accept # Inline reject from InvitationControllerConcerns def reject @workshop = WorkshopPresenter.decorate(@invitation.workshop) - closes_at = @invitation.workshop.rsvp_closes_at - rsvp_deadline = [@invitation.workshop.date_and_time - 3.5.hours, closes_at].compact.min - if rsvp_deadline >= Time.zone.now + if @invitation.workshop.cancellations_open? if @invitation.attending.eql? false redirect_back(fallback_location: invitation_path(@invitation), notice: t('messages.not_attending_already')) else - @invitation.update!(attending: false) + freed_seat = release_seat MemberActivityRecorder.record(actor: @invitation.member, key: 'workshop_invitation.rejected', trackable: @invitation) - next_spot = WaitingList.next_spot(@invitation.workshop, @invitation.role) - - if next_spot.present? - invitation = next_spot.invitation - next_spot.destroy - invitation.update(attending: true, rsvp_time: Time.zone.now, automated_rsvp: true) - @workshop.send_attending_email(invitation, true) - end + promote_next_waitlist_member if freed_seat redirect_back( fallback_location: invitation_path(@invitation), diff --git a/app/models/waiting_list.rb b/app/models/waiting_list.rb index 0d21d7941..83e07a703 100644 --- a/app/models/waiting_list.rb +++ b/app/models/waiting_list.rb @@ -22,10 +22,6 @@ def self.coaches(workshop) by_workshop(workshop).where_role('Coach').where(auto_rsvp: true).map(&:member) end - def self.next_spot(workshop, role) - by_workshop(workshop).where_role(role).where(auto_rsvp: true).first - end - # Pops the next auto-RSVP waitlist entry and confirms its invitation. The # caller sends the attendance email for the promoted invitation. def self.promote_next(workshop, role) diff --git a/app/views/workshop_invitation/_waiting_list.html.haml b/app/views/workshop_invitation/_waiting_list.html.haml index 49d054810..e98c3a4c7 100644 --- a/app/views/workshop_invitation/_waiting_list.html.haml +++ b/app/views/workshop_invitation/_waiting_list.html.haml @@ -1,15 +1,18 @@ - if invitation.waiting_list.blank? %span.badge.bg-danger The workshop is full. %hr - - if @invitation.for_student? - = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| - = f.input :tutorial, collection: @tutorial_titles, include_blank: true - = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }, hint: 'Anything else we should know?', placeholder: 'e.g. I need help understanding selectors' - = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' + - if @workshop.waitlist_open? + - if @invitation.for_student? + = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| + = f.input :tutorial, collection: @tutorial_titles, include_blank: true + = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }, hint: 'Anything else we should know?', placeholder: 'e.g. I need help understanding selectors' + = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' + - else + = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| + = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 } + = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' - else - = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| - = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 } - = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' + %p= t('messages.waiting_list.closed') - else %p Waiting List position: #{invitation.waiting_list_position}/#{@workshop.waiting_list_count_for(invitation.role)} - if @invitation.for_student? @@ -19,5 +22,5 @@ #{@invitation.tutorial} %p #{@invitation.note} - = link_to 'Remove from the waiting list', invitation_waiting_list_path(invitation), method: :delete, class: 'btn btn-danger w-100', 'data-confirm' => 'Are you sure you want to let go of your spot? You cannot undo this.' - + - if @workshop.waitlist_open? + = link_to 'Remove from the waiting list', invitation_waiting_list_path(invitation), method: :delete, class: 'btn btn-danger w-100', 'data-confirm' => 'Are you sure you want to let go of your spot? You cannot undo this.' diff --git a/app/views/workshop_invitation/show.html.haml b/app/views/workshop_invitation/show.html.haml index 3b559abd2..b9224568f 100644 --- a/app/views/workshop_invitation/show.html.haml +++ b/app/views/workshop_invitation/show.html.haml @@ -65,7 +65,7 @@ = simple_form_for @invitation, url: :invitation, method: :put do |f| = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 } = f.button :button, 'Update note', class: 'btn btn-primary w-100 mb-2' - - if @workshop.rsvp_available? + - if @workshop.cancellations_open? = link_to 'I can no longer attend', reject_invitation_url(@invitation), class: 'btn btn-danger w-100', role: 'button' - else %p= t('workshop.invitation.cant_make_it_note', email: @workshop.chapter.email) diff --git a/config/locales/en.yml b/config/locales/en.yml index 31974feeb..4983dacd9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -198,6 +198,8 @@ en: updated_details: "Invitation details successfully updated." closed: "RSVPs for this workshop are now closed." rsvps_closed: "RSVPs have now closed." + waiting_list: + closed: "You can no longer join or leave the waiting list. RSVPs have now closed for this workshop." invalid_format: "The requested format is invalid: %{invalid_format}" notifications: provider_already_connected: "You are already signed in!" diff --git a/spec/controllers/admin/invitations_controller_spec.rb b/spec/controllers/admin/invitations_controller_spec.rb index 6fd2e067d..f46d16274 100644 --- a/spec/controllers/admin/invitations_controller_spec.rb +++ b/spec/controllers/admin/invitations_controller_spec.rb @@ -101,6 +101,51 @@ expect(response).to redirect_to(admin_workshop_rsvp_url(workshop)) end + it 'promotes the next waitlisted member when the workshop is in the future' do + invitation.update!(attending: true) + waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil) + WaitingList.add(waitlisted_invitation, true) + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' } + + expect(waitlisted_invitation.reload.attending).to be(true) + end + + it 'does not promote when the removed invitation never held a seat' do + invitation.update!(attending: nil) + waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil) + WaitingList.add(waitlisted_invitation, true) + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' } + + expect(waitlisted_invitation.reload.attending).to be_nil + end + + it 'does not promote when the removed invitation already declined' do + invitation.update!(attending: false) + waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil) + WaitingList.add(waitlisted_invitation, true) + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' } + + expect(waitlisted_invitation.reload.attending).to be_nil + end + + it 'does not promote from the waiting list when the workshop has started' do + workshop.update!(date_and_time: 1.hour.ago, ends_at: 1.hour.ago + 2.hours) + invitation.update!(attending: true) + waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil) + WaitingList.add(waitlisted_invitation, true) + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' } + + expect(waitlisted_invitation.reload.attending).to be_nil + end + it 'redirects back preserving the search term and page' do request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop, q: 'Zoe', page: 2) diff --git a/spec/controllers/waiting_lists_controller_spec.rb b/spec/controllers/waiting_lists_controller_spec.rb index cf09b26c4..4a5d96cbd 100644 --- a/spec/controllers/waiting_lists_controller_spec.rb +++ b/spec/controllers/waiting_lists_controller_spec.rb @@ -45,6 +45,54 @@ end.to change(WaitingList, :count).by(1) end end + + context 'when the custom close time has passed' do + let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 1.hour.ago, date_and_time: 4.hours.from_now) } + + it 'does not create a waiting list entry' do + expect do + post :create, params: { invitation_id: invitation.token } + end.not_to change(WaitingList, :count) + end + + it 'redirects with a closed message' do + post :create, params: { invitation_id: invitation.token } + + expect(flash[:notice]).to include('RSVPs have now closed for this workshop') + end + end + + context 'when the 3.5 hour freeze has been reached' do + let(:workshop) { Fabricate(:workshop, date_and_time: 3.hours.from_now) } + + it 'does not create a waiting list entry' do + expect do + post :create, params: { invitation_id: invitation.token } + end.not_to change(WaitingList, :count) + end + end + + context 'when the custom close time is later than the freeze' do + let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 2.hours.from_now, date_and_time: 4.hours.from_now) } + + # The earlier of the two instants is the gate: joins still work until + # the freeze, even though the custom close time has not passed yet. + it 'creates a waiting list entry' do + expect do + post :create, params: { invitation_id: invitation.token } + end.to change(WaitingList, :count).by(1) + end + end + + context 'when the freeze passed but the custom close time is still in the future' do + let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 2.hours.from_now, date_and_time: 3.hours.from_now) } + + it 'does not create a waiting list entry' do + expect do + post :create, params: { invitation_id: invitation.token } + end.not_to change(WaitingList, :count) + end + end end describe 'DELETE #destroy' do @@ -60,5 +108,40 @@ end.to change(WaitingList, :count).by(-1) end end + + context 'when the waitlist is closed' do + let(:waiting_list) { Fabricate(:waiting_list) } + let(:invitation) { waiting_list.invitation } + + before do + invitation.workshop.update!(date_and_time: 3.hours.from_now) + invitation # materialize the fabricated waiting list entry outside the change block + end + + it 'keeps the entry on the waiting list' do + expect do + delete :destroy, params: { invitation_id: invitation.token } + end.not_to change(WaitingList, :count) + end + + it 'redirects with a closed message' do + delete :destroy, params: { invitation_id: invitation.token } + + expect(flash[:notice]).to include('RSVPs have now closed for this workshop') + end + end + + context 'when the waitlist is open' do + let(:waiting_list) { Fabricate(:waiting_list) } + let(:invitation) { waiting_list.invitation } + + before { invitation } # materialize the fabricated waiting list entry outside the change block + + it 'removes the entry' do + expect do + delete :destroy, params: { invitation_id: invitation.token } + end.to change(WaitingList, :count).by(-1) + end + end end end diff --git a/spec/controllers/workshop_invitation_controller_spec.rb b/spec/controllers/workshop_invitation_controller_spec.rb index 2e8a3762e..250e17a4a 100644 --- a/spec/controllers/workshop_invitation_controller_spec.rb +++ b/spec/controllers/workshop_invitation_controller_spec.rb @@ -144,7 +144,7 @@ def html_body(mail) end end - context 'when past deadline' do + context 'when the custom close time passed but the 3.5 hour freeze has not' do before do workshop.update!( rsvp_closes_at: 1.hour.ago, @@ -152,6 +152,25 @@ def html_body(mail) ) end + it 'sets attending to false' do + post :reject, params: { id: invitation.token } + expect(invitation.reload.attending).to be false + end + + it 'redirects with rejection message' do + post :reject, params: { id: invitation.token } + expect(flash[:notice]).to include('so sad you') + end + end + + context 'when past the 3.5 hour freeze' do + before do + workshop.update!( + rsvp_closes_at: 2.hours.from_now, + date_and_time: 3.hours.from_now + ) + end + it 'does not change attendance' do post :reject, params: { id: invitation.token } expect(invitation.reload.attending).to be_nil @@ -175,33 +194,51 @@ def html_body(mail) end end - context 'when someone is on waiting list' do - let(:waitlisted_member) { Fabricate(:member) } - let(:waitlisted_invitation) { Fabricate(:workshop_invitation, workshop:, member: waitlisted_member, role: 'Student') } + context 'when the freeze time has been reached exactly' do + before { workshop.update!(date_and_time: 3.5.hours.from_now) } + + it 'does not change attendance' do + post :reject, params: { id: invitation.token } + expect(invitation.reload.attending).to be_nil + end + + it 'redirects with deadline message' do + post :reject, params: { id: invitation.token } + expect(flash[:notice]).to include('3.5 hours') + end + end + + context 'when the member is waitlisted and has not RSVPed' do + let(:later_waitlisted_invitation) { Fabricate(:workshop_invitation, workshop:, member: Fabricate(:member), role: 'Student') } before do - invitation.update!(attending: true) - WaitingList.add(waitlisted_invitation, auto_rsvp: true) + WaitingList.add(invitation, true) + WaitingList.add(later_waitlisted_invitation, true) end - it 'promotes waiting list member' do + it 'sets attending to false' do post :reject, params: { id: invitation.token } - expect(waitlisted_invitation.reload.attending).to be true + expect(invitation.reload.attending).to be false end - it 'emails the promoted member a confirmation they are attending' do + it 'does not promote another waiting list member' do post :reject, params: { id: invitation.token } + expect(later_waitlisted_invitation.reload.attending).to be_nil + end + end + + context 'when someone is on waiting list' do + let(:waitlisted_member) { Fabricate(:member) } + let(:waitlisted_invitation) { Fabricate(:workshop_invitation, workshop:, member: waitlisted_member, role: 'Student') } - mail = ActionMailer::Base.deliveries.find { |m| m.to.include?(waitlisted_member.email) } - expect(mail).not_to be_nil - expect(html_body(mail)).to include('been confirmed') + before do + invitation.update!(attending: true) + WaitingList.add(waitlisted_invitation, auto_rsvp: true) end - it 'sends the promotion variant of the email (waiting-list flag set)' do + it 'promotes waiting list member' do post :reject, params: { id: invitation.token } - - mail = ActionMailer::Base.deliveries.find { |m| m.to.include?(waitlisted_member.email) } - expect(html_body(mail)).to include('A spot became available and your attendance has now been confirmed!') + expect(waitlisted_invitation.reload.attending).to be true end it 'does not email anyone else (the rejecting member gets no promotion copy)' do @@ -209,6 +246,11 @@ def html_body(mail) post :reject, params: { id: invitation.token } end.to change { ActionMailer::Base.deliveries.count }.by(1) end + + it 'removes the promoted member from the waiting list' do + post :reject, params: { id: invitation.token } + expect(WaitingList.where(invitation: waitlisted_invitation)).not_to exist + end end context 'when a coach is waitlisted and a student seat frees up' do @@ -231,6 +273,22 @@ def html_body(mail) .not_to change(WaitingList, :count) end end + + context 'when cancellation happens after the custom close time' do + let(:waitlisted_member) { Fabricate(:member) } + let(:waitlisted_invitation) { Fabricate(:workshop_invitation, workshop:, member: waitlisted_member, role: 'Student') } + + before do + workshop.update!(rsvp_closes_at: 1.hour.ago, date_and_time: 4.hours.from_now) + invitation.update!(attending: true) + WaitingList.add(waitlisted_invitation, true) + end + + it 'promotes waiting list member' do + post :reject, params: { id: invitation.token } + expect(waitlisted_invitation.reload.attending).to be true + end + end end describe 'PATCH #update' do diff --git a/spec/models/waiting_list_spec.rb b/spec/models/waiting_list_spec.rb index a1ef9fafe..a4016a6c1 100644 --- a/spec/models/waiting_list_spec.rb +++ b/spec/models/waiting_list_spec.rb @@ -17,24 +17,6 @@ end end - describe '#next_spot' do - it 'returns the next spot to be allocated' do - invitation = Fabricate(:workshop_invitation, workshop:) - described_class.add(invitation) - - expect(described_class.next_spot(workshop, 'Student').invitation).to eq(invitation) - end - - it 'ignores an older entry for another role' do - coach_invitation = Fabricate(:coach_workshop_invitation, workshop:, member: Fabricate(:coach)) - described_class.add(coach_invitation) - - expect(described_class.next_spot(workshop, 'Student')).to be_nil - expect(coach_invitation.reload.attending).to be_nil - expect(described_class.by_workshop(workshop).count).to eq(1) - end - end - describe '#promote_next' do it 'confirms the next auto-RSVP invitation and removes its waitlist entry' do invitation = Fabricate(:workshop_invitation, workshop:) diff --git a/spec/support/shared_examples/behaves_like_an_invitation_route.rb b/spec/support/shared_examples/behaves_like_an_invitation_route.rb index bf4d0debf..59dad29be 100644 --- a/spec/support/shared_examples/behaves_like_an_invitation_route.rb +++ b/spec/support/shared_examples/behaves_like_an_invitation_route.rb @@ -87,14 +87,15 @@ WaitingList.add(waitinglisted) invitation.update_attribute(:attending, true) visit invitation_route - expect(WaitingList.next_spot(invitation.workshop, invitation.role).present?).to be(true) + expect(WaitingList.by_workshop(invitation.workshop).count).to eq(1) click_on 'I can no longer attend' expect(page).to have_text(I18n.t('messages.rejected_invitation', name: invitation.member.name)) + expect(waitinglisted.reload.attending).to be(true) expect(waitinglisted.reload.automated_rsvp).to be(true) expect(waitinglisted.reload.rsvp_time).not_to be_nil - expect(WaitingList.next_spot(invitation.workshop, invitation.role).present?).to be(false) + expect(WaitingList.by_workshop(invitation.workshop).count).to be_zero end scenario 'when they are successful by accessing the link directly' do @@ -151,6 +152,17 @@ expect(page).to have_text('You can only change your RSVP status up to 3.5 hours before the workshop') expect(page).to have_current_path(invitation_route, ignore_query: true) end + + scenario 'when the custom RSVP close time passed but the workshop is more than 3.5 hours away' do + invitation.workshop.update!(rsvp_closes_at: 1.hour.ago, date_and_time: Time.zone.now + 4.hours) + invitation.update_attribute(:attending, true) + visit invitation_route + + expect(page).to have_link 'I can no longer attend' + + click_on 'I can no longer attend' + expect(page).to have_text(I18n.t('messages.rejected_invitation', name: invitation.member.name)) + end end context 'when waiting list' do @@ -164,5 +176,15 @@ click_on 'Remove from the waiting list' expect(page).to have_text('You have been removed from the waiting list') end + + scenario 'is closed after the RSVP close time' do + invitation.workshop.update!(rsvp_closes_at: 1.hour.ago, date_and_time: Time.zone.now + 4.hours) + set_no_available_slots + visit invitation_route + + expect(page).to have_text('RSVPs have now closed for this workshop') + expect(page).to have_no_button 'Join the waiting list' + expect(page).to have_no_link 'Remove from the waiting list' + end end end From f3f19888a72d93b263dca76d5aec2b59ff572f77 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 9 Oct 2026 15:35:06 +0200 Subject: [PATCH 4/4] fix(waitlist): raise when promotion fails to remove entry promote_next already raises through update! when confirming the invitation fails, so a silent destroy failure left the entry on the waitlist while the invitation became attending, allowing a repeat promotion and duplicate attending email. destroy! rolls the whole transaction back instead. --- app/models/waiting_list.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/waiting_list.rb b/app/models/waiting_list.rb index 83e07a703..de38eb15d 100644 --- a/app/models/waiting_list.rb +++ b/app/models/waiting_list.rb @@ -33,7 +33,7 @@ def self.promote_next(workshop, role) return unless next_spot invitation = next_spot.invitation - next_spot.destroy + next_spot.destroy! invitation.update!(attending: true, rsvp_time: Time.zone.now, automated_rsvp: true) invitation end