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
19 changes: 15 additions & 4 deletions app/controllers/check_ins_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

class CheckInsController < ApplicationController
include RoleChoiceConcerns
before_action :load_check_in_target
before_action :store_referer_path, only: [:new]
before_action :authenticate_member!, only: %i[new create confirm]
Expand All @@ -17,7 +18,17 @@ def create

if existing
return redirect_to check_in_confirm_path if already_checked_in?(existing)
return redirect_to check_in_new_path, alert: mismatch_role_message(existing.role) if existing.role != role

if existing.role != role
unless role_changeable?(existing)
return redirect_to check_in_new_path, alert: mismatch_role_message(existing.role)
end
unless role_permitted?(current_user, role)
return redirect_to check_in_new_path, alert: not_subscribed_role_message(role)
end
end
elsif !role_permitted?(current_user, role)
return redirect_to check_in_new_path, alert: not_subscribed_role_message(role)
end

unless @check_in_target.check_in_open?
Expand All @@ -34,7 +45,7 @@ def create
return redirect_to check_in_new_path, alert: "There are no #{role} spaces left."
end

mark_attended(invitation)
mark_attended(invitation, role)
redirect_to check_in_confirm_path
rescue ActionController::ParameterMissing
redirect_to check_in_new_path, alert: 'Please select a valid role (Student or Coach).'
Expand Down Expand Up @@ -108,8 +119,8 @@ def find_or_create_invitation(role)
end
end

def mark_attended(invitation)
attrs = { attending: true, source: InvitationConcerns::SOURCE_CHECK_IN }
def mark_attended(invitation, role)
attrs = { role:, attending: true, source: InvitationConcerns::SOURCE_CHECK_IN }
if @check_in_target.is_a?(Event)
attrs[:verified] = true
else
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/concerns/role_choice_concerns.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

# Role choice for members subscribed as both student and coach (issue #3040).
# The member's choice wins and the role can change until they accept or join a
# waiting list; a cancelled member can choose again. Same semantics as
# WorkshopsController#find_or_create_invitation.
module RoleChoiceConcerns
extend ActiveSupport::Concern

ROLES = %w[Student Coach].freeze

private

# The role the request asks for. Falls back to the invitation's role unless
# the member may choose: they must be subscribed to the role and the
# invitation must still be changeable. Callers write it in the same update
# that completes the action, so a refused request changes nothing.
def chosen_role(invitation)
role = params[:role]
return invitation.role unless role.in?(ROLES)
return invitation.role unless role_changeable?(invitation)
return invitation.role unless role_permitted?(invitation.member, role)

role
end

# A member may only take a role they are subscribed to. Members subscribed as
# both student and coach can therefore choose, and the choice is open on the
# server for anyone the views already offer it to.
def role_permitted?(member, role)
role == 'Student' ? member.student? : member.coach?
end

def not_subscribed_role_message(role)
"You are not subscribed as a #{role}."
end

def role_changeable?(invitation)
return false if invitation.attending?
return false if invitation.respond_to?(:waiting_list) && invitation.waiting_list.present?

true
end

def role_choice_available?(invitation)
member = invitation.member
member.student? && member.coach? && role_changeable?(invitation)
end
end
8 changes: 6 additions & 2 deletions app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class InvitationsController < ApplicationController
include RoleChoiceConcerns
# The invitation token in the URL is the authenticator for these actions;
# CSRF is redundant and fails when browsers withhold the session cookie
# (e.g. Safari/WebKit ITP on cross-site navigation). Same rationale as
Expand Down Expand Up @@ -26,6 +27,7 @@ def show
@event = EventPresenter.new(@invitation.event)
@host_address = AddressPresenter.new(@event.venue.address) if @event.venue.present?
@member = @invitation.member
@role_choice = role_choice_available?(@invitation) && !@member.already_attending(@event)
end

def attend
Expand All @@ -36,8 +38,10 @@ def attend
notice: t('messages.invitations.rsvps_closed')
end

if @invitation.student_spaces? || @invitation.coach_spaces?
@invitation.update!(attending: true)
role = chosen_role(@invitation)

if (role == 'Student' && event.student_spaces?) || (role == 'Coach' && event.coach_spaces?)
@invitation.update!(attending: true, role:)

MemberActivityRecorder.record(actor: @invitation.member, key: 'event_invitation.rsvp',
trackable: @invitation)
Expand Down
35 changes: 23 additions & 12 deletions app/controllers/waiting_lists_controller.rb
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
class WaitingListsController < ApplicationController
include WorkshopInvitationConcerns
include RoleChoiceConcerns

# The invitation token in the URL is the authenticator for these actions;
# CSRF is redundant and fails when browsers withhold the session cookie
# (e.g. Safari/WebKit ITP on cross-site navigation). Same rationale as
# FeedbackController#submit (PR #2641, Rollbar #535).
skip_forgery_protection only: %i[create destroy]

def create # rubocop:disable Metrics/MethodLength
def create
# 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)
@invitation.assign_attributes(invitation_params.merge(role: chosen_role(@invitation)))

return back_with_message(@invitation.errors.full_messages) unless @invitation.valid?(:waitinglist)

@invitation.save && WaitingList.add(@invitation, auto_rsvp)
MemberActivityRecorder.record(actor: @invitation.member, key: 'waiting_list.joined',
trackable: @invitation)
join_waiting_list

message = if auto_rsvp
'You have been added to the waiting list'
else
'We will send you an email if any spots become available'
end

back_with_message(message)
back_with_message(waiting_list_message)
end

def destroy
Expand All @@ -47,6 +40,24 @@ def destroy

private

def join_waiting_list
@invitation.save!
WaitingList.add(@invitation, auto_rsvp)
MemberActivityRecorder.record(
actor: @invitation.member,
key: 'waiting_list.joined',
trackable: @invitation
)
end

def waiting_list_message
if auto_rsvp
'You have been added to the waiting list'
else
'We will send you an email if any spots become available'
end
end

def waitlist_closed?
!@invitation.workshop.waitlist_open?
end
Expand Down
14 changes: 9 additions & 5 deletions app/controllers/workshop_invitation_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class WorkshopInvitationController < ApplicationController
include WorkshopInvitationConcerns
include WaitlistPromotionConcerns
include RoleChoiceConcerns

# NOTE: This controller handles workshop invitations (WorkshopInvitation model).
# It provides accept/reject RSVP actions for workshop attendees via token-based links.
Expand All @@ -17,6 +18,7 @@ def show
@tutorial_titles = Tutorial.all_titles

@workshop = WorkshopPresenter.decorate(@invitation.workshop)
@role_choice = role_choice_available?(@invitation)

render plain: @workshop.attendees_csv if request.format.csv?
end
Expand All @@ -42,10 +44,12 @@ def accept

return back_with_message(t('messages.already_invited')) if attending_or_waitlisted?(workshop, user)

role = chosen_role(@invitation)

@workshop = WorkshopPresenter.decorate(@invitation.workshop)
return back_with_message(t('messages.no_available_seats')) unless available_spaces?(@workshop, @invitation)
return back_with_message(t('messages.no_available_seats')) unless available_spaces?(@workshop, role)

if @invitation.update(invitation_params.merge!(attending: true, rsvp_time: Time.zone.now))
if @invitation.update(invitation_params.merge!(attending: true, rsvp_time: Time.zone.now, role:))
MemberActivityRecorder.record(actor: @invitation.member, key: 'workshop_invitation.rsvp',
trackable: @invitation)
@workshop.send_attending_email(@invitation)
Expand Down Expand Up @@ -92,9 +96,9 @@ def invitation_params
end
end

def available_spaces?(workshop, invitation)
(invitation.role.eql?('Student') && workshop.event_student_spaces?) ||
(invitation.role.eql?('Coach') && workshop.event_coach_spaces?)
def available_spaces?(workshop, role)
(role == 'Student' && workshop.event_student_spaces?) ||
(role == 'Coach' && workshop.event_coach_spaces?)
end

# Inline from InvitationControllerConcerns
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/workshops_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class WorkshopsController < ApplicationController
include RoleChoiceConcerns

before_action :authenticate_member!, except: [:show]
before_action :set_workshop, only: %i[show rsvp]

Expand Down Expand Up @@ -37,6 +39,10 @@ def rsvp
return redirect_back fallback_location: root_path, notice: t('workshops.already_wish_to_attend')
end

unless role_permitted?(current_user, role_params)
return redirect_back fallback_location: root_path, notice: not_subscribed_role_message(role_params)
end

@invitation = find_or_create_invitation(@workshop, current_user, role_params)
end

Expand Down
14 changes: 12 additions & 2 deletions app/views/invitations/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@

.row.mt-4
.col-12.col-md-9
- if @invitation.for_student?
- if @role_choice && @event.tito_url.blank?
%p
You are subscribed as both a student and a coach.
%strong Choose how you would like to attend.
- if @event.event_student_spaces?
= link_to 'RSVP as a student', event_attend_path(role: 'Student'), class: 'btn btn-primary', method: :post
- if @event.event_coach_spaces?
= link_to 'RSVP as a coach', event_attend_path(role: 'Coach'), class: 'btn btn-primary', method: :post
- unless @event.event_student_spaces? || @event.event_coach_spaces?
%p Unfortunately there are no spots left. Send us an email at #{@event.email} to be added to the waiting list.
- elsif @invitation.for_student?
- if !@invitation.attending
- if @member.already_attending(@event)
%p You have already RSVP'd to this event as a coach! Please cancel through the email confirmation you received if you wish to join us as a student instead.
Expand All @@ -30,7 +40,7 @@
= link_to event_reject_path, class: 'btn btn-danger', method: :post, 'data-confirm' => "Are you sure you won't be able to attend the event? There may not be any spots available later on." do
Cancel my spot

- if @invitation.for_coach?
- elsif @invitation.for_coach?
- if !@invitation.attending
- if @member.already_attending(@event)
%p You have already RSVP'd to this event as a student! Please cancel through the email confirmation you received if you wish to sign up as a coach instead.
Expand Down
15 changes: 8 additions & 7 deletions app/views/workshop_invitation/_waiting_list.html.haml
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
-# locals: (invitation:)
-# locals: (invitation:, role: nil, button_label: nil)
- role ||= invitation.role
- button_label ||= 'Join the waiting list'
- if invitation.waiting_list.blank?
%span.badge.bg-danger The workshop is full.
%hr
- if @workshop.waitlist_open?
- if @invitation.for_student?
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
= simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f|
- if role == 'Student'
= 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|
- else
= 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'
= hidden_field_tag :role, role
= f.button :button, button_label, class: 'btn btn-primary w-100 mb-0'
- else
%p= t('messages.waiting_list.closed')
- else
Expand Down
28 changes: 25 additions & 3 deletions app/views/workshop_invitation/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
= render partial: 'shared/announcements', locals: { announcements: @announcements }

.row
.col-12.col-md-7.col-lg-8
.col-12.col-md-6
%p#introduction
Hi #{@invitation.member.name},
= render partial: @invitation.role.downcase
Expand All @@ -44,7 +44,7 @@
%li= t('workshop.virtual.invitation.info.line_5')
%li= t('workshop.virtual.invitation.info.line_6')

.col-12.col-md-5.col-lg-4
.col-12.col-md-6
.card.border-info.mb-3
- if @workshop.past?
.card-body
Expand Down Expand Up @@ -79,7 +79,29 @@
- else
= @invitation.member.bans.active.first.reason
- else
- if @invitation.for_coach?
- if @role_choice
%p
You are subscribed as both a student and a coach.
%strong Choose how you would like to attend.
.row
.col-12.col-md-6
%h4 As a student
- if @workshop.event_student_spaces?
= simple_form_for @invitation, url: accept_invitation_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, 'Attend as a student', name: 'role', value: 'Student', class: 'btn btn-primary w-100 mb-0'
- else
= render partial: 'workshop_invitation/waiting_list', locals: { invitation: @invitation, role: 'Student', button_label: 'Join the student waiting list' }
.col-12.col-md-6
%h4 As a coach
- if @workshop.event_coach_spaces?
= simple_form_for @invitation, url: accept_invitation_path(@invitation), method: :post do |f|
= f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }
= f.button :button, 'Attend as a coach', name: 'role', value: 'Coach', class: 'btn btn-primary w-100 mb-0'
- else
= render partial: 'workshop_invitation/waiting_list', locals: { invitation: @invitation, role: 'Coach', button_label: 'Join the coach waiting list' }
- elsif @invitation.for_coach?
- if @workshop.event_coach_spaces?
= link_to 'Keep your skills up-to-date!', edit_member_path
%span.d-block
Expand Down
Loading
Loading