From 28de72504cbaa2265d770b8408ff564207b3138f Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 16 Oct 2026 08:36:02 +0200 Subject: [PATCH 1/7] feat(invitations): let dual-role members choose their role at RSVP A member subscribed to a chapter as both student and coach received one invitation whose role was frozen to whichever invitation pass ran first, so they could only attend in that role (issue #3040). The invitation landing page now offers both role buttons to such members, and joining the waiting list takes the chosen role. The member's choice wins until they accept or join a waiting list, and a cancelled member can choose again. A role assignment requires a subscription to that role, and the role is written with the action it completes, so a refused request changes nothing. Single-role members keep the current flow. --- .../concerns/role_choice_concerns.rb | 49 ++++++++++ app/controllers/invitations_controller.rb | 8 +- app/controllers/waiting_lists_controller.rb | 31 ++++--- .../workshop_invitation_controller.rb | 14 ++- app/controllers/workshops_controller.rb | 6 ++ app/views/invitations/show.html.haml | 14 ++- .../_waiting_list.html.haml | 15 +-- app/views/workshop_invitation/show.html.haml | 24 ++++- .../invitations_controller_spec.rb | 92 +++++++++++++++++++ .../waiting_lists_controller_spec.rb | 19 ++++ .../workshop_invitation_controller_spec.rb | 56 +++++++++++ spec/controllers/workshops_controller_spec.rb | 19 ++++ spec/features/accepting_invitation_spec.rb | 42 +++++++++ ...haves_like_managing_workshop_attendance.rb | 6 +- 14 files changed, 363 insertions(+), 32 deletions(-) create mode 100644 app/controllers/concerns/role_choice_concerns.rb diff --git a/app/controllers/concerns/role_choice_concerns.rb b/app/controllers/concerns/role_choice_concerns.rb new file mode 100644 index 000000000..e26022cbf --- /dev/null +++ b/app/controllers/concerns/role_choice_concerns.rb @@ -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.eql?('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 diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 335b89c7f..05b1c8806 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -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 @@ -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 @@ -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.eql?('Student') && event.student_spaces?) || (role.eql?('Coach') && event.coach_spaces?) + @invitation.update!(attending: true, role:) MemberActivityRecorder.record(actor: @invitation.member, key: 'event_invitation.rsvp', trackable: @invitation) diff --git a/app/controllers/waiting_lists_controller.rb b/app/controllers/waiting_lists_controller.rb index adb980f36..ac924a143 100644 --- a/app/controllers/waiting_lists_controller.rb +++ b/app/controllers/waiting_lists_controller.rb @@ -1,5 +1,6 @@ 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 @@ -7,26 +8,18 @@ class WaitingListsController < ApplicationController # 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) - - 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 + join_waiting_list - back_with_message(message) + back_with_message(waiting_list_message) end def destroy @@ -47,6 +40,20 @@ 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 diff --git a/app/controllers/workshop_invitation_controller.rb b/app/controllers/workshop_invitation_controller.rb index 7008fdfc1..5e535fe10 100644 --- a/app/controllers/workshop_invitation_controller.rb +++ b/app/controllers/workshop_invitation_controller.rb @@ -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. @@ -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 @@ -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) @@ -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.eql?('Student') && workshop.event_student_spaces?) || + (role.eql?('Coach') && workshop.event_coach_spaces?) end # Inline from InvitationControllerConcerns diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index c5115214f..7a9b6d59b 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -1,4 +1,6 @@ class WorkshopsController < ApplicationController + include RoleChoiceConcerns + before_action :authenticate_member!, except: [:show] before_action :set_workshop, only: %i[show rsvp] @@ -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 diff --git a/app/views/invitations/show.html.haml b/app/views/invitations/show.html.haml index e7727670d..c05ba0369 100644 --- a/app/views/invitations/show.html.haml +++ b/app/views/invitations/show.html.haml @@ -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. @@ -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. diff --git a/app/views/workshop_invitation/_waiting_list.html.haml b/app/views/workshop_invitation/_waiting_list.html.haml index c98a41b04..e21401c22 100644 --- a/app/views/workshop_invitation/_waiting_list.html.haml +++ b/app/views/workshop_invitation/_waiting_list.html.haml @@ -1,17 +1,18 @@ --# locals: (invitation:) +-# locals: invitation:, role: (defaults to the invitation role), button_label: (optional) +- role = local_assigns.fetch(:role, invitation.role) +- button_label = local_assigns.fetch(: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.eql?('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 diff --git a/app/views/workshop_invitation/show.html.haml b/app/views/workshop_invitation/show.html.haml index b9224568f..4e22cf771 100644 --- a/app/views/workshop_invitation/show.html.haml +++ b/app/views/workshop_invitation/show.html.haml @@ -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 diff --git a/spec/controllers/invitations_controller_spec.rb b/spec/controllers/invitations_controller_spec.rb index cbd789f8a..13a0a5520 100644 --- a/spec/controllers/invitations_controller_spec.rb +++ b/spec/controllers/invitations_controller_spec.rb @@ -10,6 +10,47 @@ expect(response).to have_http_status(:not_found) end end + + context 'when the member is subscribed as both student and coach' do + render_views + + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + before do + member = invitation.member + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + end + + it 'renders the role choice to a dual-role member who has not RSVPd' do + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).to include('Choose how you would like to attend') + expect(response.body).to include('RSVP as a student') + expect(response.body).to include('RSVP as a coach') + end + + it 'renders no role choice when the member already attends the event in another role' do + Fabricate(:attending_event_invitation, event:, member: invitation.member, role: 'Coach') + + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).not_to include('Choose how you would like to attend') + expect(response.body).to include("You have already RSVP'd to this event as a coach") + end + end + + context 'when the member is subscribed in a single role' do + render_views + + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + it 'renders no role choice' do + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).not_to include('Choose how you would like to attend') + end + end end describe 'POST #attend' do @@ -50,6 +91,57 @@ expect(invitation.reload.attending).to be true end end + + context 'when the member chooses a role' do + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + before { Fabricate(:subscription, member: invitation.member, group: Fabricate(:group, name: 'Coaches')) } + + it 'accepts in the chosen role' do + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + it 'keeps the invitation role when the chosen role is full' do + event.update!(coach_spaces: 0) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).not_to be true + end + + it 'does not change the role of an invitation that is already accepted' do + invitation.update!(attending: true) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + end + + it 'lets a cancelled member choose a role again' do + invitation.update!(attending: true, verified: true, role: 'Student') + invitation.update!(attending: false) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + end + + context 'when the member is not subscribed to the chosen role' do + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + it 'keeps the invitation role and accepts' do + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + end end describe 'POST #reject' do diff --git a/spec/controllers/waiting_lists_controller_spec.rb b/spec/controllers/waiting_lists_controller_spec.rb index 474d066d2..a75c48947 100644 --- a/spec/controllers/waiting_lists_controller_spec.rb +++ b/spec/controllers/waiting_lists_controller_spec.rb @@ -33,6 +33,25 @@ expect(WaitingList.where(invitation:).count).to eq(1) end + context 'when the member chooses a role' do + before { Fabricate(:subscription, member: invitation.member, group: Fabricate(:group, name: 'Coaches')) } + + it 'joins the waiting list for the chosen role' do + post :create, params: { invitation_id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.waiting_list).to be_present + end + + it 'does not change the role of an invitation already on the waiting list' do + post :create, params: { invitation_id: invitation.token } + + post :create, params: { invitation_id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + 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). diff --git a/spec/controllers/workshop_invitation_controller_spec.rb b/spec/controllers/workshop_invitation_controller_spec.rb index d230ed78f..3deed45c8 100644 --- a/spec/controllers/workshop_invitation_controller_spec.rb +++ b/spec/controllers/workshop_invitation_controller_spec.rb @@ -80,6 +80,16 @@ post :accept, params: { id: invitation.token } expect(flash[:notice]).to include('no more spots left') end + + it 'keeps the invitation role when the chosen role is full' do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + invitation.update!(role: 'Coach') + + post :accept, params: { id: invitation.token, role: 'Student' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be_nil + end end context 'when tutorial is missing for student' do @@ -108,6 +118,52 @@ expect(invitation.reload.attending).to be true end end + + context 'when the member chooses a role' do + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + + it 'accepts in the chosen role' do + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + it 'ignores an unknown role and keeps the invitation role' do + post :accept, params: { id: invitation.token, role: 'Hacker' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + + it 'does not change the role of an invitation that is already accepted' do + invitation.update!(attending: true) + + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + end + + it 'keeps the chosen role when the accept is invalid' do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + invitation.update!(tutorial: nil, role: 'Coach') + + post :accept, params: { id: invitation.token, role: 'Student' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be_nil + expect(flash[:notice].join).to include('Tutorial') + end + end + + context 'when the member is not subscribed to the chosen role' do + it 'keeps the invitation role and accepts' do + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + end end describe 'POST #reject' do diff --git a/spec/controllers/workshops_controller_spec.rb b/spec/controllers/workshops_controller_spec.rb index f08b6dd0b..df0600832 100644 --- a/spec/controllers/workshops_controller_spec.rb +++ b/spec/controllers/workshops_controller_spec.rb @@ -60,6 +60,8 @@ Fabricate(:workshop_invitation, workshop:, member:, role: 'Coach', attending: nil) end + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'redirects to the existing invitation page' do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -78,6 +80,8 @@ Fabricate(:workshop_invitation, workshop:, member:, role: 'Student', attending: nil) end + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'updates the existing invitation to the requested role instead of creating a second one' do expect do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -89,6 +93,8 @@ end context 'when the member does not have an invitation for the workshop and role' do + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'creates a new invitation and redirects' do expect do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -99,6 +105,19 @@ end end + context 'when the member is not subscribed to the requested role' do + let!(:invitation) do + Fabricate(:workshop_invitation, workshop:, member:, role: 'Student', attending: nil) + end + + it 'keeps the invitation role' do + post :rsvp, params: { id: workshop.id, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(flash[:notice]).to eq('You are not subscribed as a Coach.') + end + end + context 'when the member is already attending' do before do Fabricate(:attending_workshop_invitation, workshop:, member:, role: 'Coach') diff --git a/spec/features/accepting_invitation_spec.rb b/spec/features/accepting_invitation_spec.rb index d42a8dd4e..df88be711 100644 --- a/spec/features/accepting_invitation_spec.rb +++ b/spec/features/accepting_invitation_spec.rb @@ -24,6 +24,48 @@ it_behaves_like 'invitation route' + context 'when the member is subscribed as both student and coach' do + before do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + end + + scenario 'offers the choice of role' do + visit invitation_route + + expect(page).to have_text('Choose how you would like to attend') + expect(page).to have_button('Attend as a student') + expect(page).to have_button('Attend as a coach') + end + + scenario 'accepts in the chosen role' do + visit invitation_route + click_on 'Attend as a coach' + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + scenario 'joins the waiting list for the chosen role when that role is full' do + set_no_available_slots + + visit invitation_route + click_on 'Join the student waiting list' + + expect(invitation.reload.role).to eq('Student') + expect(invitation.waiting_list).to be_present + end + end + + context 'when the member is subscribed in one role' do + scenario 'shows no role choice' do + visit invitation_route + + expect(page).to have_button('Attend') + expect(page).to have_no_text('Choose how you would like to attend') + end + end + context 'when workshop is full' do scenario 'shows waiting list option when there are no available seats' do set_no_available_slots diff --git a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb index ae9d9ea88..f5277d5a7 100644 --- a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb +++ b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb @@ -61,7 +61,7 @@ click_on 'Attend as a student' select tutorial.title, from: :workshop_invitation_tutorial - click_on 'Attend' + click_on 'Attend as a student' expect(page).to have_text('See you at the workshop') @@ -71,7 +71,7 @@ it 'can get to the RSVP as a coach page' do click_on 'Attend as a coach' - click_on 'Attend' + click_on 'Attend as a coach' expect(page).to have_text('See you at the workshop') @@ -93,7 +93,7 @@ visit workshop_path(workshop) click_on 'Attend as a coach' - click_on 'Attend' + click_on 'Attend as a coach' visit workshop_path(workshop) From fdc5164e6e979c7b62ce5579d6fa7031c2aaefd6 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 16 Oct 2026 08:41:56 +0200 Subject: [PATCH 2/7] feat(check-in): let undecided members check in in the role they pick Self check-in refused a role that differed from the invitation role, which trapped dual-role members who had been invited as a student at the door (issue #3040). The door choice now changes the role of an undecided invitation, written together with the check-in so a refused check-in changes nothing. The role must be one the member is subscribed to, and a member without subscriptions checks in in the role of their invitation. Invitations that are accepted or on a waiting list keep their role and get the existing message instead. --- app/controllers/check_ins_controller.rb | 19 ++++-- spec/controllers/check_ins_controller_spec.rb | 68 ++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/app/controllers/check_ins_controller.rb b/app/controllers/check_ins_controller.rb index 89343201d..6cd98b99a 100644 --- a/app/controllers/check_ins_controller.rb +++ b/app/controllers/check_ins_controller.rb @@ -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] @@ -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? @@ -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).' @@ -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 diff --git a/spec/controllers/check_ins_controller_spec.rb b/spec/controllers/check_ins_controller_spec.rb index 0a79402da..a14e0e153 100644 --- a/spec/controllers/check_ins_controller_spec.rb +++ b/spec/controllers/check_ins_controller_spec.rb @@ -31,7 +31,10 @@ end context 'when logged in' do - before { login(member) } + before do + login(member) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + end it 'creates an invitation with source=check_in' do post :create, params: { code: event.check_in_code, role: 'Student' } @@ -48,6 +51,16 @@ expect(flash[:alert]).to eq('Check-in is not currently open.') end + it 'keeps the role when check-in is refused' do + Fabricate(:invitation, event: future_event, member:, role: 'Student') + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + + post :create, params: { code: future_event.check_in_code, role: 'Coach' } + + expect(flash[:alert]).to eq('Check-in is not currently open.') + expect(Invitation.find_by(event: future_event, member:).role).to eq('Student') + end + it 'rejects an invalid role' do post :create, params: { code: event.check_in_code, role: 'Hacker' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) @@ -68,11 +81,62 @@ expect(Invitation.find(invitation.id).verified).to be true end - it 'rejects selecting a different role than the existing invitation' do + it 'checks in an undecided invitation in the chosen role' do Fabricate(:invitation, event:, member:, role: 'Student') + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + post :create, params: { code: event.check_in_code, role: 'Coach' } + invitation = Invitation.find_by(event:, member:) + expect(invitation.role).to eq('Coach') + expect(invitation.verified).to be true + expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) + end + + it 'rejects a role change when the member already accepted' do + Fabricate(:invitation, event:, member:, role: 'Student', attending: true) post :create, params: { code: event.check_in_code, role: 'Coach' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) expect(flash[:alert]).to include('Student') + expect(Invitation.find_by(event:, member:).role).to eq('Student') + end + + it 'rejects a role change when the member is on the waiting list' do + workshop = Fabricate(:workshop, + date_and_time: Time.zone.now - 30.minutes, + ends_at: Time.zone.now + 30.minutes) + invitation = Fabricate(:workshop_invitation, workshop:, member:, role: 'Student') + WaitingList.add(invitation) + post :create, params: { code: workshop.check_in_code, role: 'Coach' } + expect(response).to redirect_to(check_in_w_path(code: workshop.check_in_code)) + expect(flash[:alert]).to include('Student') + expect(invitation.reload.role).to eq('Student') + end + + it 'refuses check-in in a role the member is not subscribed to' do + post :create, params: { code: event.check_in_code, role: 'Coach' } + + expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) + expect(flash[:alert]).to eq('You are not subscribed as a Coach.') + end + + it 'refuses a role change to a role the member is not subscribed to' do + Fabricate(:invitation, event:, member:, role: 'Student') + + post :create, params: { code: event.check_in_code, role: 'Coach' } + + expect(flash[:alert]).to eq('You are not subscribed as a Coach.') + expect(Invitation.find_by(event:, member:).role).to eq('Student') + end + + it 'checks in an unsubscribed member in the role of their invitation' do + other = Fabricate(:member) + Fabricate(:invitation, event:, member: other, role: 'Coach') + login(other) + + post :create, params: { code: event.check_in_code, role: 'Coach' } + + invitation = Invitation.find_by(event:, member: other) + expect(invitation.role).to eq('Coach') + expect(invitation.verified).to be true end it 'rejects check-in when the role is at capacity' do From 2a94bcb4cc4d63d89291da3e25e48242bdd20c4b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 16 Oct 2026 07:47:07 +0200 Subject: [PATCH 3/7] fix(invitations): even out content and attendance card columns The invitation landing page split its row 8/4, leaving the attendance card cramped. Both columns now take 6 from the md breakpoint up. --- app/views/workshop_invitation/show.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/workshop_invitation/show.html.haml b/app/views/workshop_invitation/show.html.haml index 4e22cf771..9e536f22e 100644 --- a/app/views/workshop_invitation/show.html.haml +++ b/app/views/workshop_invitation/show.html.haml @@ -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 @@ -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 From 5857925c2350ade0952d1fdc2536b324c7f829a8 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 16 Oct 2026 12:36:02 +0200 Subject: [PATCH 4/7] style(invitations): use == for role comparison and reformat the record call Review feedback from Olle: == is the conventional choice for string comparison, and the record call wrapped better with one argument per line. No RuboCop rule covers the eql? preference, so this matches the convention by hand. --- app/controllers/concerns/role_choice_concerns.rb | 2 +- app/controllers/invitations_controller.rb | 2 +- app/controllers/waiting_lists_controller.rb | 7 +++++-- app/controllers/workshop_invitation_controller.rb | 4 ++-- app/views/workshop_invitation/_waiting_list.html.haml | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/concerns/role_choice_concerns.rb b/app/controllers/concerns/role_choice_concerns.rb index e26022cbf..e0ef737da 100644 --- a/app/controllers/concerns/role_choice_concerns.rb +++ b/app/controllers/concerns/role_choice_concerns.rb @@ -28,7 +28,7 @@ def chosen_role(invitation) # 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.eql?('Student') ? member.student? : member.coach? + role == 'Student' ? member.student? : member.coach? end def not_subscribed_role_message(role) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 05b1c8806..e07818ef8 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -40,7 +40,7 @@ def attend role = chosen_role(@invitation) - if (role.eql?('Student') && event.student_spaces?) || (role.eql?('Coach') && event.coach_spaces?) + 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', diff --git a/app/controllers/waiting_lists_controller.rb b/app/controllers/waiting_lists_controller.rb index ac924a143..d38684a05 100644 --- a/app/controllers/waiting_lists_controller.rb +++ b/app/controllers/waiting_lists_controller.rb @@ -42,8 +42,11 @@ def destroy def join_waiting_list @invitation.save && WaitingList.add(@invitation, auto_rsvp) - MemberActivityRecorder.record(actor: @invitation.member, key: 'waiting_list.joined', - trackable: @invitation) + MemberActivityRecorder.record( + actor: @invitation.member, + key: 'waiting_list.joined', + trackable: @invitation + ) end def waiting_list_message diff --git a/app/controllers/workshop_invitation_controller.rb b/app/controllers/workshop_invitation_controller.rb index 5e535fe10..0577eee57 100644 --- a/app/controllers/workshop_invitation_controller.rb +++ b/app/controllers/workshop_invitation_controller.rb @@ -97,8 +97,8 @@ def invitation_params end def available_spaces?(workshop, role) - (role.eql?('Student') && workshop.event_student_spaces?) || - (role.eql?('Coach') && workshop.event_coach_spaces?) + (role == 'Student' && workshop.event_student_spaces?) || + (role == 'Coach' && workshop.event_coach_spaces?) end # Inline from InvitationControllerConcerns diff --git a/app/views/workshop_invitation/_waiting_list.html.haml b/app/views/workshop_invitation/_waiting_list.html.haml index e21401c22..ef966b467 100644 --- a/app/views/workshop_invitation/_waiting_list.html.haml +++ b/app/views/workshop_invitation/_waiting_list.html.haml @@ -6,7 +6,7 @@ %hr - if @workshop.waitlist_open? = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| - - if role.eql?('Student') + - 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' - else From 2dfdb459090335326077dfd503f7acb59e6a115d Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 17 Oct 2026 11:19:27 +0200 Subject: [PATCH 5/7] fix(waiting lists): raise when the invitation will not save A false save skipped WaitingList.add while the joined activity was still recorded and the success message still shown. The valid?(:waitinglist) call in create means a failing save is unexpected, so raise on it. --- app/controllers/waiting_lists_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/waiting_lists_controller.rb b/app/controllers/waiting_lists_controller.rb index d38684a05..a5115cf9b 100644 --- a/app/controllers/waiting_lists_controller.rb +++ b/app/controllers/waiting_lists_controller.rb @@ -41,7 +41,8 @@ def destroy private def join_waiting_list - @invitation.save && WaitingList.add(@invitation, auto_rsvp) + @invitation.save! + WaitingList.add(@invitation, auto_rsvp) MemberActivityRecorder.record( actor: @invitation.member, key: 'waiting_list.joined', From f07655c17e258c674a66197831fd23fcffd095a6 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 17 Oct 2026 11:19:35 +0200 Subject: [PATCH 6/7] fix(workshop invitations): restore the strict locals magic comment The role-choice rework dropped the parentheses around the locals list, so ActionView no longer parsed the magic comment and the partial accepted any locals again. Declare role and button_label with nil defaults and read them directly. --- app/views/workshop_invitation/_waiting_list.html.haml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/workshop_invitation/_waiting_list.html.haml b/app/views/workshop_invitation/_waiting_list.html.haml index ef966b467..e406eaa73 100644 --- a/app/views/workshop_invitation/_waiting_list.html.haml +++ b/app/views/workshop_invitation/_waiting_list.html.haml @@ -1,6 +1,6 @@ --# locals: invitation:, role: (defaults to the invitation role), button_label: (optional) -- role = local_assigns.fetch(:role, invitation.role) -- button_label = local_assigns.fetch(:button_label, 'Join the waiting list') +-# 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 From bb3e93cfc69dfe98c3a068857ef389f3116a8398 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 17 Oct 2026 11:19:35 +0200 Subject: [PATCH 7/7] test(invitations): explain the double press on the RSVP buttons The workshop page button and the invitation form submit share the role label after the role-choice change, so the same click_on appears twice in these examples. --- .../behaves_like_managing_workshop_attendance.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb index f5277d5a7..0a99332b6 100644 --- a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb +++ b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb @@ -61,6 +61,7 @@ click_on 'Attend as a student' select tutorial.title, from: :workshop_invitation_tutorial + # Same label as the workshop page: the first press opens the form, this one submits it. click_on 'Attend as a student' expect(page).to have_text('See you at the workshop') @@ -71,6 +72,7 @@ it 'can get to the RSVP as a coach page' do click_on 'Attend as a coach' + # Same label as the workshop page: the first press opens the form, this one submits it. click_on 'Attend as a coach' expect(page).to have_text('See you at the workshop') @@ -93,6 +95,7 @@ visit workshop_path(workshop) click_on 'Attend as a coach' + # Same label as the workshop page: the first press opens the form, this one submits it. click_on 'Attend as a coach' visit workshop_path(workshop)