From 73f08cf7519481e6e9acf1606160e78b69983556 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 18 Sep 2026 14:48:35 +0200 Subject: [PATCH 1/3] feat: default RSVP close time to 3.5 hours before start --- app/models/concerns/rsvp_closable.rb | 21 ++++++++++ app/models/event.rb | 5 +-- app/models/meeting.rb | 1 + app/models/workshop.rb | 11 +++-- spec/models/event_spec.rb | 1 + spec/models/meeting_spec.rb | 3 ++ spec/models/workshop_spec.rb | 15 +++++++ .../behaves_like_rsvp_closable.rb | 41 +++++++++++++++++++ 8 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 app/models/concerns/rsvp_closable.rb create mode 100644 spec/support/shared_examples/behaves_like_rsvp_closable.rb diff --git a/app/models/concerns/rsvp_closable.rb b/app/models/concerns/rsvp_closable.rb new file mode 100644 index 000000000..ad1bb3a33 --- /dev/null +++ b/app/models/concerns/rsvp_closable.rb @@ -0,0 +1,21 @@ +module RsvpClosable + extend ActiveSupport::Concern + + DEFAULT_RSVPS_CLOSE_OFFSET = 3.5.hours + + included do + include InstanceMethods + end + + 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 + end + + def rsvp_available? + effective_rsvp_closes_at.future? + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 19f46231a..101dcaf33 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -3,6 +3,7 @@ class Event < ApplicationRecord include Listable include Invitable include CheckInable + include RsvpClosable attr_accessor :local_date, :local_time, :local_end_time @@ -108,9 +109,7 @@ def fetch_duplicated_sponsors end def venue_or_remote_must_be_present - if !virtual && !venue - errors.add(:venue, 'must be set, or event must be marked as virtual') - end + errors.add(:venue, 'must be set, or event must be marked as virtual') unless virtual || venue end def sponsors_uniqueness diff --git a/app/models/meeting.rb b/app/models/meeting.rb index a499f72b0..7e758ace4 100644 --- a/app/models/meeting.rb +++ b/app/models/meeting.rb @@ -2,6 +2,7 @@ class Meeting < ApplicationRecord include DateTimeConcerns include Listable include Invitable + include RsvpClosable attr_accessor :local_date, :local_time, :local_end_time diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 02064f707..349e1cd84 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -3,6 +3,7 @@ class Workshop < ApplicationRecord include Invitable include CheckInable include Listable + include RsvpClosable attr_accessor :local_date, :local_time, :local_end_time, :rsvp_open_local_date, :rsvp_open_local_time, :rsvp_close_local_date, :rsvp_close_local_time @@ -69,12 +70,6 @@ def valid_host? host? && host.address.present? end - def rsvp_available? - return rsvp_closes_at.future? if rsvp_closes_at - - future? - end - def open_for_rsvp? rsvp_opens_at&.past? end @@ -129,6 +124,10 @@ def rsvp_closes_at super.in_time_zone(time_zone) end + def effective_rsvp_closes_at + rsvp_closes_at || super + end + private def set_opens_at diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index e766c9f3a..4043fc7cb 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -5,6 +5,7 @@ include_examples 'Invitable', :invitation, :event include_examples DateTimeConcerns, :event + include_examples 'RsvpClosable' context 'with validates' do it { is_expected.to validate_presence_of(:name) } diff --git a/spec/models/meeting_spec.rb b/spec/models/meeting_spec.rb index 84660b86a..202549865 100644 --- a/spec/models/meeting_spec.rb +++ b/spec/models/meeting_spec.rb @@ -3,6 +3,9 @@ RSpec.describe Meeting do include_examples 'Invitable', :meeting_invitation, :meeting include_examples DateTimeConcerns, :meeting + include_examples 'RsvpClosable' do + subject(:meeting) { Fabricate(:meeting) } + end context 'with validations' do subject(:meeting) { Fabricate(:meeting) } diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index ca1c5ab16..f78c8df6d 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -5,6 +5,9 @@ include_examples 'Invitable', :workshop_invitation, :workshop include_examples DateTimeConcerns, :workshop + include_examples 'RsvpClosable' do + before { workshop.rsvp_closes_at = nil } + end context 'with validates' do it { is_expected.to validate_presence_of(:chapter_id) } @@ -202,6 +205,18 @@ end end + describe '#effective_rsvp_closes_at' do + context 'when a close time is set' do + it 'uses the explicit close time' do + close_time = 5.hours.from_now + workshop.rsvp_closes_at = close_time + workshop.date_and_time = 6.hours.from_now + + expect(workshop.effective_rsvp_closes_at).to eq(close_time) + end + end + end + describe '#rsvp_available?' do context 'when rsvp is available' do it 'when the event is in the future' do diff --git a/spec/support/shared_examples/behaves_like_rsvp_closable.rb b/spec/support/shared_examples/behaves_like_rsvp_closable.rb new file mode 100644 index 000000000..670bc36ba --- /dev/null +++ b/spec/support/shared_examples/behaves_like_rsvp_closable.rb @@ -0,0 +1,41 @@ +RSpec.shared_examples 'RsvpClosable' do + describe '#effective_rsvp_closes_at' do + it 'defaults to 3.5 hours before the start' do + subject.date_and_time = Time.zone.local(2026, 3, 1, 18, 30) + + expect(subject.effective_rsvp_closes_at).to eq(Time.zone.local(2026, 3, 1, 15, 0)) + end + end + + describe '#rsvp_available?' do + it 'is available more than 3.5 hours before the start' do + subject.date_and_time = 4.hours.from_now + + expect(subject.rsvp_available?).to be(true) + end + + it 'is not available exactly 3.5 hours before the start' do + subject.date_and_time = 3.5.hours.from_now + + expect(subject.rsvp_available?).to be(false) + end + + it 'is available one second before the 3.5-hour boundary' do + subject.date_and_time = 3.5.hours.from_now + 1.second + + expect(subject.rsvp_available?).to be(true) + end + + it 'is not available within 3.5 hours of the start' do + subject.date_and_time = 3.hours.from_now + + expect(subject.rsvp_available?).to be(false) + end + + it 'is not available after the start' do + subject.date_and_time = 1.hour.ago + + expect(subject.rsvp_available?).to be(false) + end + end +end From f7e15b4e320be82054cd4f342c5c6354b8fa6b65 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 18 Sep 2026 14:48:35 +0200 Subject: [PATCH 2/3] feat: block new RSVPs once the effective close time passes --- app/controllers/events_controller.rb | 2 + app/controllers/invitations_controller.rb | 8 ++++ app/views/events/_event_actions.html.haml | 15 +++--- app/views/meetings/_meeting_actions.html.haml | 3 ++ config/locales/en.yml | 2 + spec/controllers/events_controller_spec.rb | 29 ++++++++++++ .../invitations_controller_spec.rb | 46 +++++++++++++++++++ .../events/_event_actions.html.haml_spec.rb | 29 ++++++++++++ 8 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 spec/views/events/_event_actions.html.haml_spec.rb diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index fb9bd6ffd..bf80ad2ce 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -41,6 +41,8 @@ def coach def rsvp set_event + return head :forbidden unless @event.rsvp_available? + ticket = Services::Ticket.new(request, params) member = Member.find_by(email: ticket.email) invitation = member.invitations.where(event: @event, role: 'Student').first diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index f8e3f312e..335b89c7f 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -31,6 +31,10 @@ def show def attend event = @invitation.event return redirect_back fallback_location: root_path, notice: t('messages.already_rsvped') if @invitation.attending? + unless event.rsvp_available? + return redirect_back fallback_location: root_path, + notice: t('messages.invitations.rsvps_closed') + end if @invitation.student_spaces? || @invitation.coach_spaces? @invitation.update!(attending: true) @@ -77,6 +81,10 @@ def rsvp_meeting invitation = load_invitation meeting = invitation.meeting + unless meeting.rsvp_available? + return redirect_back fallback_location: root_path, + notice: t('messages.invitations.rsvps_closed') + end if invitation.update(attending: true) MemberActivityRecorder.record(actor: current_user, key: 'meeting_invitation.rsvp', diff --git a/app/views/events/_event_actions.html.haml b/app/views/events/_event_actions.html.haml index aebcbc7d2..d4d9ec248 100644 --- a/app/views/events/_event_actions.html.haml +++ b/app/views/events/_event_actions.html.haml @@ -7,12 +7,15 @@ %p.badge.bg-primary This event has already occurred. - else - if logged_in? - - unless @event.coaches_only? - = link_to event_student_rsvp_path(@event), class: 'btn btn-primary me-2' do - = t('events.attend_as_student') - - unless @event.students_only? - = link_to event_coach_rsvp_path(@event), class: 'btn btn-primary' do - = t('events.attend_as_coach') + - if @event.rsvp_available? + - unless @event.coaches_only? + = link_to event_student_rsvp_path(@event), class: 'btn btn-primary me-2' do + = t('events.attend_as_student') + - unless @event.students_only? + = link_to event_coach_rsvp_path(@event), class: 'btn btn-primary' do + = t('events.attend_as_coach') + - else + %p.badge.bg-primary= t('events.rsvps_closed') - else = link_to t('members.sign_up'), new_member_path, class: 'btn btn-primary me-2' = link_to 'Log in', login_path, class: 'btn btn-primary' diff --git a/app/views/meetings/_meeting_actions.html.haml b/app/views/meetings/_meeting_actions.html.haml index b8325de2a..5b142ae42 100644 --- a/app/views/meetings/_meeting_actions.html.haml +++ b/app/views/meetings/_meeting_actions.html.haml @@ -27,6 +27,9 @@ %strong = link_to "Can't make it anymore? Click here to cancel your spot.", meeting_cancel_path(meeting_id: @meeting.id, token: @invitation.token) + - elsif !@meeting.rsvp_available? + %p.badge.bg-warning.text-dark= t('messages.invitations.rsvps_closed') + - elsif @meeting.invitable && @meeting.not_full = link_to meeting_invitation_path(@meeting), class: 'btn btn-primary' do = 'RSVP here' diff --git a/config/locales/en.yml b/config/locales/en.yml index 729d2b661..beade9d9f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -197,6 +197,7 @@ en: spot_not_confirmed: "Your spot has not yet been confirmed. We will verify your attendance after you complete the questionnaire." updated_details: "Invitation details successfully updated." closed: "RSVPs for this workshop are now closed." + rsvps_closed: "RSVPs have now closed." invalid_format: "The requested format is invalid: %{invalid_format}" notifications: provider_already_connected: "You are already signed in!" @@ -297,6 +298,7 @@ en: schedule: "Schedule" send_us_an_email: "send us an email" not_open_for_rsvp: This event is not open for RSVP yet. + rsvps_closed: "RSVPs for this event have closed." virtual: This is a virtual event. faq: experience: diff --git a/spec/controllers/events_controller_spec.rb b/spec/controllers/events_controller_spec.rb index c846a063c..7176fc58f 100644 --- a/spec/controllers/events_controller_spec.rb +++ b/spec/controllers/events_controller_spec.rb @@ -89,6 +89,35 @@ end end + describe 'POST #rsvp' do + let(:member) { Fabricate(:member) } + let(:event) { Fabricate(:event) } + + context 'when RSVPs are open' do + it 'creates a student invitation and marks it attending' do + expect do + post :rsvp, params: { event_id: event.slug, email: member.email } + end.to change(Invitation, :count).by(1) + + invitation = Invitation.find_by!(event:, member:) + expect(invitation.attending).to be(true) + expect(response).to have_http_status(:ok) + end + end + + context 'when RSVPs have closed' do + let(:event) { Fabricate(:event, date_and_time: 2.hours.from_now) } + + it 'rejects the request without creating an invitation' do + expect do + post :rsvp, params: { event_id: event.slug, email: member.email } + end.not_to change(Invitation, :count) + + expect(response).to have_http_status(:forbidden) + end + end + end + describe '#past' do before { Fabricate(:event, date_and_time: 2.weeks.ago) } diff --git a/spec/controllers/invitations_controller_spec.rb b/spec/controllers/invitations_controller_spec.rb index 417f0f1f8..cbd789f8a 100644 --- a/spec/controllers/invitations_controller_spec.rb +++ b/spec/controllers/invitations_controller_spec.rb @@ -20,6 +20,23 @@ end end + context 'when RSVPs have closed' do + let(:event) { Fabricate(:event, date_and_time: 3.hours.from_now) } + let(:invitation) { Fabricate(:invitation, event:) } + + it 'does not accept the RSVP' do + post :attend, params: { event_id: event.id, token: invitation.token } + + expect(invitation.reload.attending).to be false + end + + it 'redirects with a closed notice' do + post :attend, params: { event_id: event.id, token: invitation.token } + + expect(flash[:notice]).to eq(I18n.t('messages.invitations.rsvps_closed')) + end + end + context 'without a CSRF token (browser did not send session cookie)' do # Simulate the real-world scenario where the browser withholds the # session cookie (e.g. Safari/WebKit ITP on cross-site navigation). @@ -54,4 +71,33 @@ end end end + + describe 'GET #rsvp_meeting' do + let(:meeting) { Fabricate(:meeting) } + let(:invitation) { Fabricate(:meeting_invitation, meeting:) } + + before { login(invitation.member) } + + it 'accepts the RSVP when RSVPs are open' do + get :rsvp_meeting, params: { meeting_id: meeting.id, token: invitation.token } + + expect(invitation.reload.attending).to be true + end + + context 'when RSVPs have closed' do + let(:meeting) { Fabricate(:meeting, date_and_time: 3.hours.from_now) } + + it 'does not accept the RSVP' do + get :rsvp_meeting, params: { meeting_id: meeting.id, token: invitation.token } + + expect(invitation.reload.attending).to be false + end + + it 'redirects with a closed notice' do + get :rsvp_meeting, params: { meeting_id: meeting.id, token: invitation.token } + + expect(flash[:notice]).to eq(I18n.t('messages.invitations.rsvps_closed')) + end + end + end end diff --git a/spec/views/events/_event_actions.html.haml_spec.rb b/spec/views/events/_event_actions.html.haml_spec.rb new file mode 100644 index 000000000..428ba4add --- /dev/null +++ b/spec/views/events/_event_actions.html.haml_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe 'events/_event_actions.html.haml' do + let(:event) { Fabricate(:event) } + + before do + assign(:event, EventPresenter.new(event)) + allow(view).to receive(:logged_in?).and_return(true) + end + + it 'renders the RSVP buttons when RSVPs are open' do + render + + expect(rendered).to have_link(t('events.attend_as_student')) + expect(rendered).to have_link(t('events.attend_as_coach')) + end + + context 'when RSVPs have closed' do + let(:event) { Fabricate(:event, date_and_time: 2.hours.from_now) } + + it 'shows the closed badge and hides the RSVP buttons' do + render + + expect(rendered).to include(t('events.rsvps_closed')) + expect(rendered).to have_no_link(t('events.attend_as_student')) + expect(rendered).to have_no_link(t('events.attend_as_coach')) + end + end +end From c987502da7f3db794c3cc34a7ccefa44d28fbf03 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 18 Sep 2026 14:48:35 +0200 Subject: [PATCH 3/3] feat: prefill the RSVP close time on the workshop form --- app/assets/javascripts/application.js | 1 + app/assets/javascripts/rsvp-close-time.js | 67 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 app/assets/javascripts/rsvp-close-time.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index b3d749d7c..80e690f64 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -20,6 +20,7 @@ //= require subscriptions-toggle //= require add-all-chapters //= require invitations +//= require rsvp-close-time //= require dietary-restrictions //= require cocoon //= require font_awesome5 diff --git a/app/assets/javascripts/rsvp-close-time.js b/app/assets/javascripts/rsvp-close-time.js new file mode 100644 index 000000000..d763a73ae --- /dev/null +++ b/app/assets/javascripts/rsvp-close-time.js @@ -0,0 +1,67 @@ +// Prefills the RSVP close time on the workshop form with the default +// (start time minus 3.5 hours). Only fills fields the organiser has left +// empty or that this script filled earlier — an already set or saved close +// time is never overwritten. The subtraction uses browser-local calendar +// minutes, which can differ from the server's absolute-seconds deadline +// across a DST transition inside the 3.5-hour window. +(function () { + 'use strict'; + + var CLOSE_OFFSET_MINUTES = 210; // 3.5 hours + var START_FIELDS = /^workshop\[local_(date|time)\]$/; + var CLOSE_DATE = 'workshop[rsvp_close_local_date]'; + var CLOSE_TIME = 'workshop[rsvp_close_local_time]'; + + function pad(value) { + return String(value).padStart(2, '0'); + } + + function field(form, name) { + return form.querySelector('[name="' + name + '"]'); + } + + function defaultCloseValues(startDate, startTime) { + if (!startDate) return null; + + var start = new Date(startDate + 'T' + (startTime || '00:00')); + if (isNaN(start.getTime())) return null; + + start.setMinutes(start.getMinutes() - CLOSE_OFFSET_MINUTES); + + return { + date: start.getFullYear() + '-' + pad(start.getMonth() + 1) + '-' + pad(start.getDate()), + time: pad(start.getHours()) + ':' + pad(start.getMinutes()) + }; + } + + function prefill(form) { + var closeDate = field(form, CLOSE_DATE); + var closeTime = field(form, CLOSE_TIME); + if (!closeDate || !closeTime) return; + + var close = defaultCloseValues(field(form, 'workshop[local_date]').value, + field(form, 'workshop[local_time]').value); + if (!close) return; + + fillIfDefault(closeDate, close.date); + fillIfDefault(closeTime, close.time); + } + + // Fills empty fields and refreshes values this script previously filled, + // so a corrected start time keeps its default close time. Values the + // organiser typed or that were saved with the workshop are never touched. + function fillIfDefault(field, value) { + if (!field.value || field.value === field.dataset.rsvpPrefilled) { + field.value = value; + field.dataset.rsvpPrefilled = value; + } + } + + document.addEventListener('change', function (event) { + var target = event.target; + if (!target.name || !START_FIELDS.test(target.name)) return; + + var form = target.closest('form'); + if (form) prefill(form); + }); +})();