Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions app/assets/javascripts/rsvp-close-time.js
Original file line number Diff line number Diff line change
@@ -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);
});
})();
2 changes: 2 additions & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions app/models/concerns/rsvp_closable.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 2 additions & 3 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class Event < ApplicationRecord
include Listable
include Invitable
include CheckInable
include RsvpClosable

attr_accessor :local_date, :local_time, :local_end_time

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/meeting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class Meeting < ApplicationRecord
include DateTimeConcerns
include Listable
include Invitable
include RsvpClosable

attr_accessor :local_date, :local_time, :local_end_time

Expand Down
11 changes: 5 additions & 6 deletions app/models/workshop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions app/views/events/_event_actions.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions app/views/meetings/_meeting_actions.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions spec/controllers/events_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
46 changes: 46 additions & 0 deletions spec/controllers/invitations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions spec/models/event_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
3 changes: 3 additions & 0 deletions spec/models/meeting_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
15 changes: 15 additions & 0 deletions spec/models/workshop_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading