Skip to content

Commit 0d1a071

Browse files
committed
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.
1 parent 6208d1f commit 0d1a071

12 files changed

Lines changed: 260 additions & 64 deletions

‎app/controllers/admin/invitations_controller.rb‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class Admin::InvitationsController < Admin::ApplicationController
22
include Admin::WorkshopConcerns
3+
include WaitlistPromotionConcerns
34

45
def update
56
set_and_decorate_workshop
@@ -80,10 +81,12 @@ def attending_failed
8081
end
8182

8283
def update_to_not_attending
83-
@invitation.update!(attending: false, last_overridden_by_id: current_user.id)
84+
freed_seat = release_seat(last_overridden_by_id: current_user.id)
8485
MemberActivityRecorder.record(actor: current_user, key: 'invitation.rsvp_override',
8586
trackable: @invitation, recipient: @invitation.member)
8687

88+
promote_next_waitlist_member if freed_seat && @workshop.future?
89+
8790
{
8891
message: "You have removed #{@invitation.member.full_name} from the workshop.",
8992
error: false

‎app/controllers/waiting_lists_controller.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class WaitingListsController < ApplicationController
88
skip_forgery_protection only: %i[create destroy]
99

1010
def create # rubocop:disable Metrics/MethodLength
11+
# Joins and leaves stop at the earlier of the RSVP close time and the
12+
# 3.5-hour freeze, so the waitlist handed to security stays stable.
13+
return back_with_message(t('messages.waiting_list.closed')) if waitlist_closed?
14+
1115
@invitation.assign_attributes(invitation_params)
1216

1317
return back_with_message(@invitation.errors.full_messages) unless @invitation.valid?(:waitinglist)
@@ -26,6 +30,8 @@ def create # rubocop:disable Metrics/MethodLength
2630
end
2731

2832
def destroy
33+
return back_with_message(t('messages.waiting_list.closed')) if waitlist_closed?
34+
2935
WaitingList.find_by(invitation_id: @invitation.id).destroy
3036
MemberActivityRecorder.record(actor: @invitation.member, key: 'waiting_list.left',
3137
trackable: @invitation)
@@ -35,6 +41,10 @@ def destroy
3541

3642
private
3743

44+
def waitlist_closed?
45+
!@invitation.workshop.waitlist_open?
46+
end
47+
3848
def token
3949
params.permit(:invitation_id)[:invitation_id]
4050
end

‎app/controllers/workshop_invitation_controller.rb‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class WorkshopInvitationController < ApplicationController
22
include WorkshopInvitationConcerns
3+
include WaitlistPromotionConcerns
34

45
# NOTE: This controller handles workshop invitations (WorkshopInvitation model).
56
# It provides accept/reject RSVP actions for workshop attendees via token-based links.
@@ -57,25 +58,16 @@ def accept
5758
# Inline reject from InvitationControllerConcerns
5859
def reject
5960
@workshop = WorkshopPresenter.decorate(@invitation.workshop)
60-
closes_at = @invitation.workshop.rsvp_closes_at
61-
rsvp_deadline = [@invitation.workshop.date_and_time - 3.5.hours, closes_at].compact.min
62-
if rsvp_deadline >= Time.zone.now
61+
if @invitation.workshop.cancellations_open?
6362
if @invitation.attending.eql? false
6463
redirect_back(fallback_location: invitation_path(@invitation),
6564
notice: t('messages.not_attending_already'))
6665
else
67-
@invitation.update!(attending: false)
66+
freed_seat = release_seat
6867
MemberActivityRecorder.record(actor: @invitation.member, key: 'workshop_invitation.rejected',
6968
trackable: @invitation)
7069

71-
next_spot = WaitingList.next_spot(@invitation.workshop, @invitation.role)
72-
73-
if next_spot.present?
74-
invitation = next_spot.invitation
75-
next_spot.destroy
76-
invitation.update(attending: true, rsvp_time: Time.zone.now, automated_rsvp: true)
77-
@workshop.send_attending_email(invitation, true)
78-
end
70+
promote_next_waitlist_member if freed_seat
7971

8072
redirect_back(
8173
fallback_location: invitation_path(@invitation),

‎app/models/waiting_list.rb‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ def self.coaches(workshop)
2222
by_workshop(workshop).where_role('Coach').where(auto_rsvp: true).map(&:member)
2323
end
2424

25-
def self.next_spot(workshop, role)
26-
by_workshop(workshop).where_role(role).where(auto_rsvp: true).first
27-
end
28-
2925
# Pops the next auto-RSVP waitlist entry and confirms its invitation. The
3026
# caller sends the attendance email for the promoted invitation.
3127
def self.promote_next(workshop, role)
Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
- if invitation.waiting_list.blank?
22
%span.badge.bg-danger The workshop is full.
33
%hr
4-
- if @invitation.for_student?
5-
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
6-
= f.input :tutorial, collection: @tutorial_titles, include_blank: true
7-
= 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'
8-
= f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0'
4+
- if @workshop.waitlist_open?
5+
- if @invitation.for_student?
6+
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
7+
= f.input :tutorial, collection: @tutorial_titles, include_blank: true
8+
= 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'
9+
= f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0'
10+
- else
11+
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
12+
= f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }
13+
= f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0'
914
- else
10-
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
11-
= f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }
12-
= f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0'
15+
%p= t('messages.waiting_list.closed')
1316
- else
1417
%p Waiting List position: <strong>#{invitation.waiting_list_position}</strong>/#{@workshop.waiting_list_count_for(invitation.role)}
1518
- if @invitation.for_student?
@@ -19,5 +22,5 @@
1922
#{@invitation.tutorial}
2023
%p
2124
#{@invitation.note}
22-
= 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.'
23-
25+
- if @workshop.waitlist_open?
26+
= 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.'

‎app/views/workshop_invitation/show.html.haml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
= simple_form_for @invitation, url: :invitation, method: :put do |f|
6666
= f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }
6767
= f.button :button, 'Update note', class: 'btn btn-primary w-100 mb-2'
68-
- if @workshop.rsvp_available?
68+
- if @workshop.cancellations_open?
6969
= link_to 'I can no longer attend', reject_invitation_url(@invitation), class: 'btn btn-danger w-100', role: 'button'
7070
- else
7171
%p= t('workshop.invitation.cant_make_it_note', email: @workshop.chapter.email)

‎config/locales/en.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ en:
198198
updated_details: "Invitation details successfully updated."
199199
closed: "RSVPs for this workshop are now closed."
200200
rsvps_closed: "RSVPs have now closed."
201+
waiting_list:
202+
closed: "You can no longer join or leave the waiting list. RSVPs have now closed for this workshop."
201203
invalid_format: "The requested format is invalid: %{invalid_format}"
202204
notifications:
203205
provider_already_connected: "You are already signed in!"

‎spec/controllers/admin/invitations_controller_spec.rb‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,51 @@
101101
expect(response).to redirect_to(admin_workshop_rsvp_url(workshop))
102102
end
103103

104+
it 'promotes the next waitlisted member when the workshop is in the future' do
105+
invitation.update!(attending: true)
106+
waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil)
107+
WaitingList.add(waitlisted_invitation, true)
108+
request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop)
109+
110+
put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' }
111+
112+
expect(waitlisted_invitation.reload.attending).to be(true)
113+
end
114+
115+
it 'does not promote when the removed invitation never held a seat' do
116+
invitation.update!(attending: nil)
117+
waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil)
118+
WaitingList.add(waitlisted_invitation, true)
119+
request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop)
120+
121+
put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' }
122+
123+
expect(waitlisted_invitation.reload.attending).to be_nil
124+
end
125+
126+
it 'does not promote when the removed invitation already declined' do
127+
invitation.update!(attending: false)
128+
waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil)
129+
WaitingList.add(waitlisted_invitation, true)
130+
request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop)
131+
132+
put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' }
133+
134+
expect(waitlisted_invitation.reload.attending).to be_nil
135+
end
136+
137+
it 'does not promote from the waiting list when the workshop has started' do
138+
workshop.update!(date_and_time: 1.hour.ago, ends_at: 1.hour.ago + 2.hours)
139+
invitation.update!(attending: true)
140+
waitlisted_invitation = Fabricate(:workshop_invitation, workshop:, tutorial: nil)
141+
WaitingList.add(waitlisted_invitation, true)
142+
request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop)
143+
144+
put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' }
145+
146+
expect(waitlisted_invitation.reload.attending).to be_nil
147+
end
148+
104149
it 'redirects back preserving the search term and page' do
105150
request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop, q: 'Zoe', page: 2)
106151

‎spec/controllers/waiting_lists_controller_spec.rb‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,54 @@
4545
end.to change(WaitingList, :count).by(1)
4646
end
4747
end
48+
49+
context 'when the custom close time has passed' do
50+
let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 1.hour.ago, date_and_time: 4.hours.from_now) }
51+
52+
it 'does not create a waiting list entry' do
53+
expect do
54+
post :create, params: { invitation_id: invitation.token }
55+
end.not_to change(WaitingList, :count)
56+
end
57+
58+
it 'redirects with a closed message' do
59+
post :create, params: { invitation_id: invitation.token }
60+
61+
expect(flash[:notice]).to include('RSVPs have now closed for this workshop')
62+
end
63+
end
64+
65+
context 'when the 3.5 hour freeze has been reached' do
66+
let(:workshop) { Fabricate(:workshop, date_and_time: 3.hours.from_now) }
67+
68+
it 'does not create a waiting list entry' do
69+
expect do
70+
post :create, params: { invitation_id: invitation.token }
71+
end.not_to change(WaitingList, :count)
72+
end
73+
end
74+
75+
context 'when the custom close time is later than the freeze' do
76+
let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 2.hours.from_now, date_and_time: 4.hours.from_now) }
77+
78+
# The earlier of the two instants is the gate: joins still work until
79+
# the freeze, even though the custom close time has not passed yet.
80+
it 'creates a waiting list entry' do
81+
expect do
82+
post :create, params: { invitation_id: invitation.token }
83+
end.to change(WaitingList, :count).by(1)
84+
end
85+
end
86+
87+
context 'when the freeze passed but the custom close time is still in the future' do
88+
let(:workshop) { Fabricate(:workshop, rsvp_closes_at: 2.hours.from_now, date_and_time: 3.hours.from_now) }
89+
90+
it 'does not create a waiting list entry' do
91+
expect do
92+
post :create, params: { invitation_id: invitation.token }
93+
end.not_to change(WaitingList, :count)
94+
end
95+
end
4896
end
4997

5098
describe 'DELETE #destroy' do
@@ -60,5 +108,40 @@
60108
end.to change(WaitingList, :count).by(-1)
61109
end
62110
end
111+
112+
context 'when the waitlist is closed' do
113+
let(:waiting_list) { Fabricate(:waiting_list) }
114+
let(:invitation) { waiting_list.invitation }
115+
116+
before do
117+
invitation.workshop.update!(date_and_time: 3.hours.from_now)
118+
invitation # materialize the fabricated waiting list entry outside the change block
119+
end
120+
121+
it 'keeps the entry on the waiting list' do
122+
expect do
123+
delete :destroy, params: { invitation_id: invitation.token }
124+
end.not_to change(WaitingList, :count)
125+
end
126+
127+
it 'redirects with a closed message' do
128+
delete :destroy, params: { invitation_id: invitation.token }
129+
130+
expect(flash[:notice]).to include('RSVPs have now closed for this workshop')
131+
end
132+
end
133+
134+
context 'when the waitlist is open' do
135+
let(:waiting_list) { Fabricate(:waiting_list) }
136+
let(:invitation) { waiting_list.invitation }
137+
138+
before { invitation } # materialize the fabricated waiting list entry outside the change block
139+
140+
it 'removes the entry' do
141+
expect do
142+
delete :destroy, params: { invitation_id: invitation.token }
143+
end.to change(WaitingList, :count).by(-1)
144+
end
145+
end
63146
end
64147
end

0 commit comments

Comments
 (0)