From 3929bdefae0ae37fb773cd55ba46afc9ddaebc4a Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 7 Jul 2026 14:23:04 +0200 Subject: [PATCH 1/5] feat: improve RSVP timing form section with title and side-by-side layout --- app/views/admin/workshops/_shared_form.html.haml | 10 +++++++--- config/locales/en.yml | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/views/admin/workshops/_shared_form.html.haml b/app/views/admin/workshops/_shared_form.html.haml index 1d5b4af2a..53a4c54f4 100644 --- a/app/views/admin/workshops/_shared_form.html.haml +++ b/app/views/admin/workshops/_shared_form.html.haml @@ -20,6 +20,10 @@ .col-12 .card.bg-light.border-info.mb-3 .card-body - %p= t('admin.workshop.form.rsvp_details') - = f.input :rsvp_open_local_date, as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:strftime, '%d/%m/%Y') } } - = f.input :rsvp_open_local_time, as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:time).try(:strftime, '%H:%M') } } + %h6.card-title RSVP Timing + %p.small.text-muted= t('admin.workshop.form.rsvp_details') + .row + .col-12.col-md-6 + = f.input :rsvp_open_local_date, label: 'Open date', as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:strftime, '%d/%m/%Y') } } + .col-12.col-md-6 + = f.input :rsvp_open_local_time, label: 'Open time', as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:time).try(:strftime, '%H:%M') } } diff --git a/config/locales/en.yml b/config/locales/en.yml index a3d64cc41..9e8c10069 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -665,7 +665,7 @@ en: failure: "Workshop cannot be deleted. This is because it has invitees and/or workshop information is on the website for a while long enough that it cannot be deleted." form: virtual_info: To create a virtual workshop you must check the box and fill in the below fields. The slack channel will be available on the invitation page for anyone who successfuly RSVPs. - rsvp_details: Fill out these fields if you want RSVPs to open automatically at a future time. Leave blank if you'll handle it manually later. + rsvp_details: Set a date and time (in the chapter's timezone) for RSVPs to open and close automatically. Leave blank to handle manually. not_invitable: "The workshop is not invitable" virtual: details: From fb5aafb59ae278571faa667aac6e51d027b0ce86 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 7 Jul 2026 11:51:26 +0200 Subject: [PATCH 2/5] feat: add rsvp_closes_at model support with validation The rsvp_closes_at column already exists on the workshops table (migration 20150211023345, added in 84f55d0d and later renamed from rsvp_close_time). This commit adds the model layer: virtual attributes, timezone-aware getter, before_validation callback, and validation that close time is before the workshop start. --- app/models/workshop.rb | 24 ++++++++++++++++++++-- spec/models/workshop_spec.rb | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 28f86ce9b..d9e191d25 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -3,7 +3,8 @@ class Workshop < ApplicationRecord include Invitable include Listable - attr_accessor :local_date, :local_time, :local_end_time, :rsvp_open_local_date, :rsvp_open_local_time + 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 resourcify :permissions, role_cname: 'Permission', role_table_name: :permission @@ -34,6 +35,8 @@ class Workshop < ApplicationRecord before_validation :set_date_and_time, :set_end_date_and_time, if: proc { |model| model.chapter_id.present? } before_validation :set_opens_at + before_validation :set_closes_at + validate :rsvp_close_before_workshop_start def host workshop_host&.sponsor @@ -78,7 +81,7 @@ def invitable_yet? end def available_for_rsvp? - invitable_yet? && date_and_time.future? + invitable_yet? && rsvp_available? end # Is this person attending this event? @@ -117,10 +120,27 @@ def rsvp_opens_at super.in_time_zone(time_zone) end + def rsvp_closes_at + return nil unless super + + super.in_time_zone(time_zone) + end + private def set_opens_at new_opens_at = datetime_from_fields(rsvp_open_local_date, rsvp_open_local_time) self.rsvp_opens_at = new_opens_at if new_opens_at end + + def set_closes_at + new_closes_at = datetime_from_fields(rsvp_close_local_date, rsvp_close_local_time) + self.rsvp_closes_at = new_closes_at if new_closes_at + end + + def rsvp_close_before_workshop_start + return unless rsvp_closes_at && date_and_time + return if rsvp_closes_at < date_and_time + errors.add(:rsvp_close_local_date, "must be before the workshop start time") + end end diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index 7fbc22bf1..cc0e8cf1a 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -45,6 +45,28 @@ it { is_expected.to validate_numericality_of(:student_spaces).is_greater_than(0) } it { is_expected.to validate_numericality_of(:coach_spaces).is_greater_than(0) } end + + context '#rsvp_closes_at' do + it 'must be before the workshop start time' do + workshop.date_and_time = Time.zone.now + 1.hour + workshop.rsvp_closes_at = Time.zone.now + 2.hours + + workshop.valid? + expect(workshop.errors[:rsvp_close_local_date]).to include("must be before the workshop start time") + end + + it 'is valid when close time is before workshop start' do + workshop.date_and_time = Time.zone.now + 2.hours + workshop.rsvp_closes_at = Time.zone.now + 1.hour + + expect(workshop.valid?).to be(true) + end + + it 'is valid when no close time is set' do + workshop.rsvp_closes_at = nil + expect(workshop.valid?).to be(true) + end + end end context 'time zone fields' do @@ -87,6 +109,24 @@ expect(workshop.rsvp_opens_at.zone).to eq('PDT') end end + + context 'rsvp_closes_at' do + it 'saves the local time in UTC' do + workshop.update!( + rsvp_close_local_date: '12/06/2015', + rsvp_close_local_time: '18:30' + ) + + expect(workshop.read_attribute(:rsvp_closes_at)).to eq(utc_time) + end + + it 'retrieves the local time from the saved UTC value' do + workshop.update_attribute(:rsvp_closes_at, utc_time) + + expect(workshop.rsvp_closes_at).to eq(pacific_time) + expect(workshop.rsvp_closes_at.zone).to eq('PDT') + end + end end context '#rsvp_available?' do From 6c263a4027599d8b42b315bb48e2e2599e0ce2ab Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 7 Jul 2026 11:55:05 +0200 Subject: [PATCH 3/5] feat: gate invitation acceptance by rsvp_closes_at --- app/controllers/admin/workshops_controller.rb | 1 + app/controllers/workshop_invitation_controller.rb | 1 + config/locales/en.yml | 1 + spec/features/accepting_invitation_spec.rb | 12 ++++++++++++ 4 files changed, 15 insertions(+) diff --git a/app/controllers/admin/workshops_controller.rb b/app/controllers/admin/workshops_controller.rb index 3e7dcd2f5..be1246af8 100644 --- a/app/controllers/admin/workshops_controller.rb +++ b/app/controllers/admin/workshops_controller.rb @@ -121,6 +121,7 @@ def workshop_params :local_date, :local_time, :local_end_time, :chapter_id, :invitable, :seats, :virtual, :slack_channel, :slack_channel_link, :rsvp_open_local_date, :rsvp_open_local_time, :description, + :rsvp_close_local_date, :rsvp_close_local_time, :coach_spaces, :student_spaces, { sponsor_ids: [] } ]) diff --git a/app/controllers/workshop_invitation_controller.rb b/app/controllers/workshop_invitation_controller.rb index 62784339a..494ba9c13 100644 --- a/app/controllers/workshop_invitation_controller.rb +++ b/app/controllers/workshop_invitation_controller.rb @@ -27,6 +27,7 @@ def accept user = current_user || @invitation.member workshop = @invitation.workshop return back_with_message(t('messages.already_rsvped')) if @invitation.attending? + return back_with_message(t('messages.invitations.closed')) unless workshop.rsvp_available? @invitation.assign_attributes(invitation_params.merge!(attending: true, rsvp_time: Time.zone.now)) return back_with_message(@invitation.errors.full_messages) unless @invitation.valid? diff --git a/config/locales/en.yml b/config/locales/en.yml index 9e8c10069..a40f43709 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -195,6 +195,7 @@ en: spot_confirmed: "Your spot has been confirmed for %{event}! We look forward to seeing you there." 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." invalid_format: "The requested format is invalid: %{invalid_format}" notifications: provider_already_connected: "You are already signed in!" diff --git a/spec/features/accepting_invitation_spec.rb b/spec/features/accepting_invitation_spec.rb index 9b6b0c2d3..252578614 100644 --- a/spec/features/accepting_invitation_spec.rb +++ b/spec/features/accepting_invitation_spec.rb @@ -22,6 +22,18 @@ it_behaves_like 'invitation route' + context 'when RSVPs are closed' do + scenario 'cannot accept an invitation after the close time' do + invitation.workshop.update(rsvp_closes_at: 1.hour.ago) + + visit invitation_route + click_on 'Attend' + + expect(page).to have_content('RSVPs for this workshop are now closed.') + expect(invitation.reload.attending).not_to be(true) + end + end + context 'amend invitation details' do context 'a student' do scenario 'cannot accept an invitation without a tutorial' do From da56e67f915644fa2db87bb11ce4ffabb8211c36 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 7 Jul 2026 14:23:28 +0200 Subject: [PATCH 4/5] feat: add RSVP close time form fields with pickadate and param test --- app/assets/javascripts/application.js | 4 ++-- app/views/admin/workshops/_shared_form.html.haml | 5 +++++ spec/controllers/admin/workshops_controller_spec.rb | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 4de962e2f..669d5a754 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -30,12 +30,12 @@ $(function() { $("body").removeClass("no-js"); - $('#event_local_date, #meeting_local_date, #workshop_local_date, #workshop_rsvp_open_local_date').pickadate({ + $('#event_local_date, #meeting_local_date, #workshop_local_date, #workshop_rsvp_open_local_date, #workshop_rsvp_close_local_date').pickadate({ format: 'dd/mm/yyyy' }); $('#announcement_expires_at, #ban_expires_at').pickadate(); $( - "#meeting_local_time, #meeting_local_end_time, #event_local_time, #event_local_end_time, #workshop_local_time, #workshop_local_end_time, #workshop_rsvp_open_local_time" + "#meeting_local_time, #meeting_local_end_time, #event_local_time, #event_local_end_time, #workshop_local_time, #workshop_local_end_time, #workshop_rsvp_open_local_time, #workshop_rsvp_close_local_time" ).pickatime({ format: "HH:i", }); diff --git a/app/views/admin/workshops/_shared_form.html.haml b/app/views/admin/workshops/_shared_form.html.haml index 53a4c54f4..6681b082c 100644 --- a/app/views/admin/workshops/_shared_form.html.haml +++ b/app/views/admin/workshops/_shared_form.html.haml @@ -27,3 +27,8 @@ = f.input :rsvp_open_local_date, label: 'Open date', as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:strftime, '%d/%m/%Y') } } .col-12.col-md-6 = f.input :rsvp_open_local_time, label: 'Open time', as: :string, input_html: { data: { value: @workshop.rsvp_opens_at.try(:time).try(:strftime, '%H:%M') } } + .row + .col-12.col-md-6 + = f.input :rsvp_close_local_date, label: 'Close date', as: :string, input_html: { data: { value: @workshop.rsvp_closes_at.try(:strftime, '%d/%m/%Y') } } + .col-12.col-md-6 + = f.input :rsvp_close_local_time, label: 'Close time', as: :string, input_html: { data: { value: @workshop.rsvp_closes_at.try(:time).try(:strftime, '%H:%M') } } diff --git a/spec/controllers/admin/workshops_controller_spec.rb b/spec/controllers/admin/workshops_controller_spec.rb index f580b75d6..fca26ee61 100644 --- a/spec/controllers/admin/workshops_controller_spec.rb +++ b/spec/controllers/admin/workshops_controller_spec.rb @@ -15,6 +15,14 @@ end end + describe 'POST #create' do + it 'permits rsvp_close_local_date and rsvp_close_local_time' do + expect do + post :create, params: { workshop: { rsvp_close_local_date: '01/12/2020', rsvp_close_local_time: '15:00', host: '' } } + end.not_to raise_error + end + end + describe 'DELETE #destroy' do context 'workshop invitations have been sent' do before do From 539d9fb25c95d5db7fb68943609b3cf05afe9adc Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 7 Jul 2026 14:23:32 +0200 Subject: [PATCH 5/5] feat: show RSVP close time on workshop detail page --- app/views/admin/workshops/show.html.haml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/views/admin/workshops/show.html.haml b/app/views/admin/workshops/show.html.haml index 6787b756b..a2964c542 100644 --- a/app/views/admin/workshops/show.html.haml +++ b/app/views/admin/workshops/show.html.haml @@ -46,6 +46,10 @@ - else RSVPs will open at = @workshop.rsvp_opens_at.strftime('%H:%M on %d/%m/%Y.') + - if @workshop.rsvp_closes_at + %div.mb-2 + RSVPs will close at + = @workshop.rsvp_closes_at.strftime('%H:%M on %d/%m/%Y.') - if @workshop.venue.present? || @workshop.virtual? %span.badge.bg-info #{@workshop.student_spaces} student spots, #{@workshop.coach_spaces} coach spots - unless @workshop.spaces?