Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/controllers/admin/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Admin::InvitationsController < Admin::ApplicationController
include Admin::WorkshopConcerns
include WaitlistPromotionConcerns

def update
set_and_decorate_workshop
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions app/controllers/concerns/waitlist_promotion_concerns.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions app/controllers/waiting_lists_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -35,6 +41,10 @@ def destroy

private

def waitlist_closed?
!@invitation.workshop.waitlist_open?
end

def token
params.permit(:invitation_id)[:invitation_id]
end
Expand Down
16 changes: 4 additions & 12 deletions app/controllers/workshop_invitation_controller.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion app/models/concerns/rsvp_closable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 15 additions & 2 deletions app/models/waiting_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@ 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
# 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)
Expand Down
15 changes: 15 additions & 0 deletions app/models/workshop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions app/views/workshop_invitation/_waiting_list.html.haml
Original file line number Diff line number Diff line change
@@ -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: <strong>#{invitation.waiting_list_position}</strong>/#{@workshop.waiting_list_count_for(invitation.role)}
- if @invitation.for_student?
Expand All @@ -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.'
2 changes: 1 addition & 1 deletion app/views/workshop_invitation/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Expand Down
45 changes: 45 additions & 0 deletions spec/controllers/admin/invitations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
83 changes: 83 additions & 0 deletions spec/controllers/waiting_lists_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading
Loading