diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index f27f714b2db82..0b37f50f4cf31 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -314,6 +314,13 @@ ignore_unused: - decidim.meetings.meetings.show.withdraw_btn_hint - decidim.meetings.meetings.show.withdraw_confirmation_html - decidim.meetings.meetings.show.withdraw_meeting + - decidim.meetings.meetings.show.cancel_waitlist + - decidim.meetings.meetings.show.join_waitlist + - decidim.meetings.meetings.show.leave + - decidim.meetings.meetings.show.registration_confirmation + - decidim.meetings.meetings.show.registration_title + - decidim.meetings.meetings.show.waitlist_confirmation + - decidim.meetings.meetings.show.waitlist_title - decidim.proposals.proposals.show.withdraw_btn_hint - decidim.proposals.proposals.show.withdraw_confirmation_html - decidim.proposals.proposals.show.withdraw_proposal diff --git a/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb b/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb index 4d629b59f23fc..5bbcc284ed0d4 100644 --- a/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb +++ b/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb @@ -204,7 +204,7 @@ class AdminEngine < ::Rails::Engine menu.add_item :assembly_share_tokens, I18n.t("menu.share_tokens", scope: "decidim.admin"), - decidim_admin_assemblies.assembly_share_tokens_path(current_assembly), + decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space), active: is_active_link?(decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space)), if: allowed_to?(:read, :share_tokens, current_participatory_space: current_participatory_space) end diff --git a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/cancel_registration_button.erb b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/cancel_registration_button.erb new file mode 100644 index 0000000000000..6646a0513c7c9 --- /dev/null +++ b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/cancel_registration_button.erb @@ -0,0 +1,20 @@ +<%= action_authorized_link_to( + :join, + meeting_registration_path(model), + resource: model, + method: :delete, + class: button_classes, + data: { disable: true, confirm: i18n_modal_confirmation_text } + ) do %> + <% if style == :simple %> + <%= cancel_button_text %> + <%= icon icon_name %> + <% elsif style == :detailed %> + + <%= icon icon_name %> + + + <%= cancel_button_text %> + + <% end %> +<% end %> diff --git a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/show.erb b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/show.erb index 727b8b7de0dde..75c17b8b4113d 100644 --- a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/show.erb +++ b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/show.erb @@ -1,13 +1,4 @@ +<%= waiting_list_info %>
- <%= action_authorized_link_to( - :join, - meeting_registration_path(model), - resource: model, - method: :delete, - class: button_classes, - data: { disable: true, confirm: t("leave_confirmation", scope: "decidim.meetings.meetings.show") } - ) do %> - <%= t("leave", scope: "decidim.meetings.meetings.show") %> - <%= icon icon_name %> - <% end %> + <%= cancel_registration_button(style: :simple) unless registration_status == "on_waiting_list" %>
diff --git a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/waiting_list_info.erb b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/waiting_list_info.erb new file mode 100644 index 0000000000000..ddcdf3034f6b1 --- /dev/null +++ b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button/waiting_list_info.erb @@ -0,0 +1,6 @@ +
+
+ <%= t("waitlist_info", scope: "decidim.meetings.meetings.show") %> + <%= cancel_registration_button(style: :detailed) %> +
+
diff --git a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button_cell.rb b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button_cell.rb index 6653eae6561bf..c9404053707d6 100644 --- a/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button_cell.rb +++ b/decidim-meetings/app/cells/decidim/meetings/cancel_registration_meeting_button_cell.rb @@ -14,14 +14,54 @@ def show render end + def waiting_list_info + render if registration_status == "on_waiting_list" + end + + def cancel_registration_button(style: :simple) + render locals: { style: style } + end + private def current_component model.component end + def registration_status + model.registrations.find_by(user: current_user)&.status + end + + def action_keys + if registration_status == "on_waiting_list" + { + button: "cancel_waitlist", + modal_title: "waitlist_title", + modal_confirmation: "waitlist_confirmation" + } + else + { + button: "leave", + modal_title: "registration_title", + modal_confirmation: "registration_confirmation" + } + end + end + + def cancel_button_text + I18n.t(action_keys[:button], scope: "decidim.meetings.meetings.show") + end + + def i18n_modal_title + I18n.t(action_keys[:modal_title], scope: "decidim.meetings.meetings.show") + end + + def i18n_modal_confirmation_text + I18n.t(action_keys[:modal_confirmation], scope: "decidim.meetings.meetings.show") + end + def button_classes - "" + registration_status == "on_waiting_list" ? "button expanded button--icon follow-button secondary hollow active small" : "" end def icon_name diff --git a/decidim-meetings/app/cells/decidim/meetings/join_meeting_button/registration_confirm.erb b/decidim-meetings/app/cells/decidim/meetings/join_meeting_button/registration_confirm.erb index 33af0ebdb7598..aa8e2f9223a9a 100644 --- a/decidim-meetings/app/cells/decidim/meetings/join_meeting_button/registration_confirm.erb +++ b/decidim-meetings/app/cells/decidim/meetings/join_meeting_button/registration_confirm.erb @@ -1,5 +1,6 @@ -
- <%= decidim_form_for(registration_form, url: meeting_registration_path(model), method: :post) do |form| %> +<% action = model.has_available_slots? ? "registration" : "waitlist" %> +
+ <%= decidim_form_for(registration_form, url: model.has_available_slots? ? meeting_registration_path(model) : join_waitlist_meeting_registration_path(model), method: :post) do |form| %>
<%= cell("decidim/represent_user_group", form) %> diff --git a/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button/show.erb b/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button/show.erb new file mode 100644 index 0000000000000..b03f1b92ed6c9 --- /dev/null +++ b/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button/show.erb @@ -0,0 +1,16 @@ +<% if model.registration_form_enabled? %> + <%= action_authorized_link_to( + :join_waitlist, + i18n_join_waitlist_text, + join_waitlist_meeting_registration_path(model), + class: button_classes + ) %> +<% else %> + <%= action_authorized_button_to( + :join_waitlist, + i18n_join_waitlist_text, + "#", + class: button_classes, + data: { open: current_user.present? ? "meeting-waitlist-confirm-#{model.id}" : "loginModal" } + ) %> +<% end %> diff --git a/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button_cell.rb b/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button_cell.rb new file mode 100644 index 0000000000000..028c5a5adae4f --- /dev/null +++ b/decidim-meetings/app/cells/decidim/meetings/join_waitlist_button_cell.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Decidim + module Meetings + # This cell renders the button to join a waitlist. + class JoinWaitlistButtonCell < Decidim::ViewModel + include MeetingCellsHelper + + def show + return unless model.waitlist_enabled? && !model.has_available_slots? && model.can_be_joined_by?(current_user) + return if model.has_registration_for?(current_user) + + render + end + + private + + def current_component + model.component + end + + def button_classes + "button expanded secondary small mt-s" + end + + def i18n_join_waitlist_text + return if !model.waitlist_enabled? && model.has_available_slots? + + I18n.t("add_to_waitlist", scope: "decidim.meetings.meetings.show") + end + + def icon_name + "clockwise-line" + end + + def registration_form + @registration_form ||= Decidim::Meetings::JoinMeetingForm.new + end + end + end +end diff --git a/decidim-meetings/app/commands/decidim/meetings/admin/copy_meeting.rb b/decidim-meetings/app/commands/decidim/meetings/admin/copy_meeting.rb index b683d64b23e57..5764e779ca32b 100644 --- a/decidim-meetings/app/commands/decidim/meetings/admin/copy_meeting.rb +++ b/decidim-meetings/app/commands/decidim/meetings/admin/copy_meeting.rb @@ -82,6 +82,7 @@ def fields_from_meeting available_slots: meeting.available_slots, registration_terms: meeting.registration_terms, reserved_slots: meeting.reserved_slots, + waitlist_enabled: meeting.waitlist_enabled, customize_registration_email: meeting.customize_registration_email, registration_form_enabled: meeting.registration_form_enabled, registration_email_custom_content: meeting.registration_email_custom_content diff --git a/decidim-meetings/app/commands/decidim/meetings/admin/update_registrations.rb b/decidim-meetings/app/commands/decidim/meetings/admin/update_registrations.rb index 987a790a2ccaa..d0e878ed86f4e 100644 --- a/decidim-meetings/app/commands/decidim/meetings/admin/update_registrations.rb +++ b/decidim-meetings/app/commands/decidim/meetings/admin/update_registrations.rb @@ -40,6 +40,7 @@ def update_meeting_registrations meeting.available_slots = form.available_slots meeting.reserved_slots = form.reserved_slots meeting.registration_terms = form.registration_terms + meeting.waitlist_enabled = form.waitlist_enabled meeting.customize_registration_email = form.customize_registration_email meeting.registration_email_custom_content = form.registration_email_custom_content if form.customize_registration_email end diff --git a/decidim-meetings/app/commands/decidim/meetings/join_waitlist.rb b/decidim-meetings/app/commands/decidim/meetings/join_waitlist.rb new file mode 100644 index 0000000000000..eae23785a2c08 --- /dev/null +++ b/decidim-meetings/app/commands/decidim/meetings/join_waitlist.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Decidim + module Meetings + # This command is executed when the user joins a waitlist for a meeting. + class JoinWaitlist < Decidim::Command + # Initializes a JoinWaitlist Command. + # + # meeting - The current instance of the meeting to be joined. + # user - The user joining the waitlist. + # registration_form - A form object with params; can be a questionnaire. + def initialize(meeting, user, registration_form) + @meeting = meeting + @user = user + @user_group = Decidim::UserGroup.find_by(id: registration_form.user_group_id) + @registration_form = registration_form + end + + # Joins the waitlist for the meeting if valid. + # + # Broadcasts :ok if successful, :invalid otherwise. + def call + return broadcast(:invalid) unless can_join_waitlist? + return broadcast(:invalid_form) unless registration_form.valid? + return broadcast(:invalid) if answer_questionnaire == :invalid + + meeting.with_lock do + create_waitlist_entry + send_waitlist_notification + end + follow_meeting + broadcast(:ok) + end + + private + + attr_reader :meeting, :user, :user_group, :registration, :registration_form + + def can_join_waitlist? + meeting.waitlist_enabled? && + !meeting.registrations.exists?(user: user) && + !meeting.has_available_slots? + end + + def create_waitlist_entry + @registration = Decidim::Meetings::Registration.create!( + meeting: meeting, + user: user, + user_group: user_group, + public_participation: registration_form.public_participation, + status: :on_waiting_list + ) + end + + def send_waitlist_notification + Decidim::EventsManager.publish( + event: "decidim.events.meetings.meeting_waitlist_added", + event_class: Decidim::Meetings::MeetingRegistrationNotificationEvent, + resource: meeting, + affected_users: [user] + ) + end + + def answer_questionnaire + return unless questionnaire? + + Decidim::Forms::AnswerQuestionnaire.call(registration_form, user, meeting.questionnaire) do + on(:ok) do + return :valid + end + + on(:invalid) do + return :invalid + end + end + end + + def questionnaire? + registration_form.model_name == "questionnaire" + end + + def follow_meeting + Decidim::CreateFollow.call(follow_form, user) + end + + def follow_form + Decidim::FollowForm + .from_params(followable_gid: meeting.to_signed_global_id.to_s) + .with_context(current_user: user) + end + end + end +end diff --git a/decidim-meetings/app/commands/decidim/meetings/leave_meeting.rb b/decidim-meetings/app/commands/decidim/meetings/leave_meeting.rb index c592030d8e164..5873555f487d3 100644 --- a/decidim-meetings/app/commands/decidim/meetings/leave_meeting.rb +++ b/decidim-meetings/app/commands/decidim/meetings/leave_meeting.rb @@ -24,6 +24,7 @@ def call destroy_registration destroy_questionnaire_answers decrement_score + move_from_waitlist! end broadcast(:ok) end @@ -50,6 +51,30 @@ def destroy_questionnaire_answers def decrement_score Decidim::Gamification.decrement_score(@user, :attended_meetings) end + + def send_email_confirmation(registration, user, meeting) + Decidim::Meetings::RegistrationMailer.confirmation(user, meeting, registration).deliver_later + end + + def move_from_waitlist! + return unless @meeting.remaining_slots.positive? + + on_waiting_list_user = @meeting.registrations.on_waiting_list.order(:created_at).first + return unless on_waiting_list_user + + on_waiting_list_user.update!(status: :registered) + send_email_confirmation(on_waiting_list_user, on_waiting_list_user.user, @meeting) + + Decidim::EventsManager.publish( + event: "decidim.events.meetings.meeting_registration_confirmed", + event_class: Decidim::Meetings::MeetingRegistrationNotificationEvent, + resource: @meeting, + affected_users: [on_waiting_list_user.user], + extra: { + registration_code: on_waiting_list_user.code + } + ) + end end end end diff --git a/decidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb b/decidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb index 7ce8d6f305cc6..3d72a4a5b682f 100644 --- a/decidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb +++ b/decidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb @@ -11,20 +11,23 @@ def answer @form = form(Decidim::Forms::QuestionnaireForm).from_params(params, session_token: session_token) - JoinMeeting.call(meeting, current_user, @form) do + waitlist = ActiveModel::Type::Boolean.new.cast(params[:waitlist]) + command = waitlist ? JoinWaitlist : JoinMeeting + + command.call(meeting, current_user, @form) do on(:ok) do - flash[:notice] = I18n.t("registrations.create.success", scope: "decidim.meetings") + flash[:notice] = I18n.t("registrations.#{waitlist ? "waitlist" : "create"}.success", scope: "decidim.meetings") redirect_to after_answer_path end on(:invalid) do - flash.now[:alert] = I18n.t("registrations.create.invalid", scope: "decidim.meetings") - render template: "decidim/forms/questionnaires/show" + flash.now[:alert] = I18n.t("registrations.#{waitlist ? "waitlist" : "create"}.invalid", scope: "decidim.meetings") + render template: "decidim/forms/questionnaires/show", status: :unprocessable_entity end on(:invalid_form) do flash.now[:alert] = I18n.t("answer.invalid", scope: i18n_flashes_scope) - render template: "decidim/forms/questionnaires/show" + render template: "decidim/forms/questionnaires/show", status: :unprocessable_entity end end end @@ -47,17 +50,37 @@ def create end end + def join_waitlist + enforce_permission_to(:join_waitlist, :meeting, meeting: meeting) + + @form = JoinMeetingForm.from_params(params).with_context(current_user: current_user) + + JoinWaitlist.call(meeting, current_user, @form) do + on(:ok) do + flash[:notice] = I18n.t("registrations.waitlist.success", scope: "decidim.meetings") + redirect_after_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("registrations.waitlist.invalid", scope: "decidim.meetings") + redirect_after_path + end + end + end + def destroy enforce_permission_to :leave, :meeting, meeting: meeting + status = registration.status + LeaveMeeting.call(meeting, current_user) do on(:ok) do - flash[:notice] = I18n.t("registrations.destroy.success", scope: "decidim.meetings") + flash[:notice] = I18n.t("registrations.destroy.#{status}.success", scope: "decidim.meetings") redirect_after_path end on(:invalid) do - flash.now[:alert] = I18n.t("registrations.destroy.invalid", scope: "decidim.meetings") + flash.now[:alert] = I18n.t("registrations.destroy.#{status}.invalid", scope: "decidim.meetings") redirect_after_path end end @@ -80,7 +103,9 @@ def decline_invitation end def allow_answers? - meeting.registrations_enabled? && meeting.registration_form_enabled? && meeting.has_available_slots? + return false unless meeting.registrations_enabled? && meeting.registration_form_enabled? + + meeting.has_available_slots? || (meeting.waitlist_enabled? && request.path.include?("join_waitlist")) end def after_answer_path @@ -90,7 +115,7 @@ def after_answer_path # You can implement this method in your controller to change the URL # where the questionnaire will be submitted. def update_url - answer_meeting_registration_path(meeting_id: meeting.id) + answer_meeting_registration_path(meeting_id: meeting.id, waitlist: params[:waitlist] || request.path.include?("join_waitlist")) end def questionnaire_for @@ -103,6 +128,10 @@ def meeting @meeting ||= Meeting.where(component: current_component).find(params[:meeting_id]) end + def registration + @registration ||= meeting.registrations.find_by(user: current_user) + end + def redirect_after_path redirect_to meeting_path(meeting) end diff --git a/decidim-meetings/app/forms/decidim/meetings/admin/meeting_registrations_form.rb b/decidim-meetings/app/forms/decidim/meetings/admin/meeting_registrations_form.rb index dccfb96e12d03..0737722bc587c 100644 --- a/decidim-meetings/app/forms/decidim/meetings/admin/meeting_registrations_form.rb +++ b/decidim-meetings/app/forms/decidim/meetings/admin/meeting_registrations_form.rb @@ -14,6 +14,7 @@ class MeetingRegistrationsForm < Decidim::Form attribute :customize_registration_email, Boolean attribute :available_slots, Integer attribute :reserved_slots, Integer + attribute :waitlist_enabled, :boolean, default: false translatable_attribute :registration_terms, String translatable_attribute :registration_email_custom_content, String @@ -21,6 +22,7 @@ class MeetingRegistrationsForm < Decidim::Form validates :registration_terms, translatable_presence: true, if: ->(form) { form.registrations_enabled? } validates :available_slots, :reserved_slots, presence: true, if: ->(form) { form.registrations_enabled? } validates :available_slots, numericality: { greater_than_or_equal_to: 0 }, if: ->(form) { form.registrations_enabled? && form.available_slots.present? } + validates :waitlist_enabled, inclusion: { in: [true, false] } validates :reserved_slots, numericality: { greater_than_or_equal_to: 0 }, if: ->(form) { form.registrations_enabled? } validates :reserved_slots, numericality: { less_than_or_equal_to: :available_slots }, if: lambda { |form| form.registrations_enabled? && diff --git a/decidim-meetings/app/models/decidim/meetings/meeting.rb b/decidim-meetings/app/models/decidim/meetings/meeting.rb index dc4089cb47bb0..decafb8000b48 100644 --- a/decidim-meetings/app/models/decidim/meetings/meeting.rb +++ b/decidim-meetings/app/models/decidim/meetings/meeting.rb @@ -196,11 +196,11 @@ def emendation? def has_available_slots? return true if available_slots.zero? - (available_slots - reserved_slots) > registrations.count + (available_slots - reserved_slots) > registrations.registered.count end def remaining_slots - available_slots - reserved_slots - registrations.count + available_slots - reserved_slots - registrations.registered.count end def has_registration_for?(user) diff --git a/decidim-meetings/app/models/decidim/meetings/registration.rb b/decidim-meetings/app/models/decidim/meetings/registration.rb index 090c7b8c2dd5c..a5e647ee68ea6 100644 --- a/decidim-meetings/app/models/decidim/meetings/registration.rb +++ b/decidim-meetings/app/models/decidim/meetings/registration.rb @@ -12,11 +12,15 @@ class Registration < Meetings::ApplicationRecord validates :user, uniqueness: { scope: :meeting } validates :code, uniqueness: { allow_blank: true, scope: :meeting } - validates :code, presence: true, on: :create + validates :code, presence: true, on: :create, if: -> { status == "registered" } + + enum status: { registered: "registered", on_waiting_list: "on_waiting_list" } before_validation :generate_code, on: :create scope :public_participant, -> { where(decidim_user_group_id: nil, public_participation: true) } + scope :registered, -> { where(status: :registered) } + scope :waiting_list, -> { where(status: :waiting_list).order(:created_at) } def self.user_collection(user) where(decidim_user_id: user.id) diff --git a/decidim-meetings/app/permissions/decidim/meetings/permissions.rb b/decidim-meetings/app/permissions/decidim/meetings/permissions.rb index 7263fad5c5e35..789891e8b4444 100644 --- a/decidim-meetings/app/permissions/decidim/meetings/permissions.rb +++ b/decidim-meetings/app/permissions/decidim/meetings/permissions.rb @@ -26,6 +26,8 @@ def permissions case permission_action.action when :join toggle_allow(can_join_meeting?) + when :join_waitlist + toggle_allow(can_join_waitlist?) when :leave toggle_allow(can_leave_meeting?) when :decline_invitation @@ -72,6 +74,13 @@ def can_join_meeting? authorized?(:join, resource: meeting) end + def can_join_waitlist? + meeting.waitlist_enabled? && + !meeting.has_available_slots? && + !meeting.has_registration_for?(user) && + authorized?(:join_waitlist, resource: meeting) + end + def can_leave_meeting? meeting.registrations_enabled? end diff --git a/decidim-meetings/app/views/decidim/meetings/admin/registrations/_form.html.erb b/decidim-meetings/app/views/decidim/meetings/admin/registrations/_form.html.erb index ff3ced798e5fb..cb26394516054 100644 --- a/decidim-meetings/app/views/decidim/meetings/admin/registrations/_form.html.erb +++ b/decidim-meetings/app/views/decidim/meetings/admin/registrations/_form.html.erb @@ -41,6 +41,11 @@

<%= t(".reserved_slots_help") %>

+
+ <%= form.check_box :waitlist_enabled %> +

<%= t(".waitlist_enabled_help") %>

+
+
<%= form.check_box :customize_registration_email, :"data-toggle" => "customize_registration_email-div" %>
diff --git a/decidim-meetings/app/views/decidim/meetings/meetings/show.html.erb b/decidim-meetings/app/views/decidim/meetings/meetings/show.html.erb index ecf5f9e3e83f5..770a36db54c4f 100644 --- a/decidim-meetings/app/views/decidim/meetings/meetings/show.html.erb +++ b/decidim-meetings/app/views/decidim/meetings/meetings/show.html.erb @@ -46,6 +46,9 @@ edit_link( <%= cell("decidim/date_range", { start: meeting.start_time, end: meeting.end_time }) %> <%= cell "decidim/meetings/join_meeting_button", meeting, big_button: true, show_remaining_slots: true %> + <% if meeting.waitlist_enabled? && !meeting.has_available_slots? && meeting.can_be_joined_by?(current_user) %> + <%= cell "decidim/meetings/join_waitlist_button", meeting %> + <% end %> <%= render partial: "decidim/shared/follow_button", locals: { followable: meeting, large: false } %>
<% if meeting.closed? && meeting.closing_visible? %> @@ -73,7 +76,7 @@ edit_link( <% end %>
- <% if registration.present? && registration.meeting.component.settings.registration_code_enabled %> + <% if registration.present? && registration.meeting.component.settings.registration_code_enabled && registration.registered? %>
diff --git a/decidim-meetings/config/locales/en.yml b/decidim-meetings/config/locales/en.yml index 67d844f945819..feeb8821e6632 100644 --- a/decidim-meetings/config/locales/en.yml +++ b/decidim-meetings/config/locales/en.yml @@ -39,6 +39,7 @@ en: title: Title transparent: Transparent type_of_meeting: Type + waitlist_enabled: Waitlist enabled meeting_agenda: title: Title visible: Visible @@ -180,6 +181,8 @@ en: email_outro: You have received this notification because you are following the "%{resource_title}" meeting. You can unfollow it from the previous link. email_subject: The "%{resource_title}" meeting was updated notification_title: The %{resource_title} meeting was updated. + meeting_waitlist_added: + notification_title: You have been added to the waiting list for the %{resource_title} meeting. registration_code_validated: email_intro: Your registration code "%{registration_code}" for the "%{resource_title}" meeting has been validated. email_outro: You have received this notification because your registration code for the "%{resource_title}" meeting has been validated. @@ -377,6 +380,7 @@ en: reserved_slots_help: Leave it to 0 if you don't have reserved slots reserved_slots_less_than: Must be less than or equal to %{count} title: Registrations + waitlist_enabled_help: If enabled, the user will be able to join the waitlist when the available slots are full. update: invalid: There was a problem saving the registration settings. success: Meeting registrations settings successfully saved. @@ -548,16 +552,18 @@ en: cancel: Cancel confirm: Confirm show: + add_to_waitlist: Join the waiting list attendees: Attendees count back: Back to list + cancel_waitlist: Leave the waiting list close_meeting: Close meeting contributions: Contributions count date: Date edit_close_meeting: Edit meeting report edit_meeting: Edit meeting join: Join meeting + join_waitlist: Join the waiting list leave: Cancel your registration - leave_confirmation: Are you sure you want to cancel your registration for this meeting? link_available_soon: Link available soon link_closed: The link to join the meeting will be available a few minutes before it starts live_event: This meeting is happening right now @@ -566,14 +572,19 @@ en: no_slots_available: No slots available organizations: Attending organizations registration_code_help_text: Your registration code + registration_confirmation: Are you sure you want to cancel your registration for this meeting? registration_state: validated: VALIDATED validation_pending: VALIDATION PENDING + registration_title: Cancel your registration remaining_slots: one: "%{count} slot remaining" other: "%{count} slots remaining" view: View visit_finished: View past meeting + waitlist_confirmation: Are you sure you want to cancel your waitlist registration for this meeting? + waitlist_info: You have joined the waiting list for this meeting. If a spot for this meeting becomes available, you will register automatically and receive a notification. + waitlist_title: Cancel your waitlist registration withdraw_btn_hint: You can withdraw your meeting if you change your mind. The meeting is not deleted, it will appear in the list of withdrawn meetings. withdraw_confirmation_html: Are you sure you want to withdraw this meeting?

This action cannot be cancelled! withdraw_meeting: Withdraw meeting @@ -645,8 +656,15 @@ en: invalid: There was a problem declining the invitation. success: You have declined the invitation successfully. destroy: - invalid: There was a problem leaving this meeting. - success: You have left the meeting successfully. + on_waiting_list: + invalid: There was a problem leaving the waiting list. + success: You have left the waiting list successfully. + registered: + invalid: There was a problem leaving this meeting. + success: You have left the meeting successfully. + waitlist: + invalid: There was a problem joining the waiting list. + success: You have joined the waiting list successfully. type_of_meeting: hybrid: Hybrid in_person: In person diff --git a/decidim-meetings/db/migrate/20250306100808_add_waitlist_enabled_to_decidim_meetings.rb b/decidim-meetings/db/migrate/20250306100808_add_waitlist_enabled_to_decidim_meetings.rb new file mode 100644 index 0000000000000..9fa8ca1405a15 --- /dev/null +++ b/decidim-meetings/db/migrate/20250306100808_add_waitlist_enabled_to_decidim_meetings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddWaitlistEnabledToDecidimMeetings < ActiveRecord::Migration[6.1] + def change + add_column :decidim_meetings_meetings, :waitlist_enabled, :boolean, default: false, null: false + end +end diff --git a/decidim-meetings/db/migrate/20250306100815_add_status_to_decidim_meetings_registrations.rb b/decidim-meetings/db/migrate/20250306100815_add_status_to_decidim_meetings_registrations.rb new file mode 100644 index 0000000000000..1a028bf8c8836 --- /dev/null +++ b/decidim-meetings/db/migrate/20250306100815_add_status_to_decidim_meetings_registrations.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddStatusToDecidimMeetingsRegistrations < ActiveRecord::Migration[6.1] + def change + add_column :decidim_meetings_registrations, :status, :string, default: "registered", null: false + add_index :decidim_meetings_registrations, :status + end +end diff --git a/decidim-meetings/lib/decidim/meetings/engine.rb b/decidim-meetings/lib/decidim/meetings/engine.rb index 866b339ffa223..cdf6f770bb614 100644 --- a/decidim-meetings/lib/decidim/meetings/engine.rb +++ b/decidim-meetings/lib/decidim/meetings/engine.rb @@ -26,6 +26,8 @@ class Engine < ::Rails::Engine get :decline_invitation get :join, action: :show post :answer + get :join_waitlist, action: :show + post :join_waitlist end end resources :versions, only: [:show, :index] diff --git a/decidim-meetings/spec/commands/admin/update_registrations_spec.rb b/decidim-meetings/spec/commands/admin/update_registrations_spec.rb index 6cb81c76055a6..18687a66f2216 100644 --- a/decidim-meetings/spec/commands/admin/update_registrations_spec.rb +++ b/decidim-meetings/spec/commands/admin/update_registrations_spec.rb @@ -28,6 +28,7 @@ module Decidim::Meetings registration_form_enabled: registration_form_enabled, available_slots: available_slots, reserved_slots: reserved_slots, + waitlist_enabled: true, customize_registration_email: customize_registration_email, registration_email_custom_content: registration_email_custom_content, registration_terms: registration_terms @@ -56,6 +57,7 @@ module Decidim::Meetings expect(meeting.customize_registration_email).to be true expect(meeting.registration_email_custom_content).to eq(registration_email_custom_content) expect(translated(meeting.registration_terms)).to eq "A legal text" + expect(meeting.waitlist_enabled).to be true end end diff --git a/decidim-meetings/spec/commands/join_waitlist_spec.rb b/decidim-meetings/spec/commands/join_waitlist_spec.rb new file mode 100644 index 0000000000000..afe06e8a70b11 --- /dev/null +++ b/decidim-meetings/spec/commands/join_waitlist_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Meetings + describe JoinWaitlist do + subject { described_class.new(meeting, user, form) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, organization: organization) } + let(:component) { create(:component, manifest_name: :meetings, participatory_space: participatory_process) } + let(:available_slots) { 2 } + let(:waitlist_enabled) { true } + let(:meeting) do + create(:meeting, + component: component, + waitlist_enabled: waitlist_enabled, + registrations_enabled: true, + available_slots: available_slots) + end + + let(:user) { create(:user, :confirmed, organization: organization, notifications_sending_frequency: "real_time") } + let(:user_group) { create(:user_group) } + let(:form_params) { { user_group_id: user_group.id } } + let(:form) do + Decidim::Meetings::JoinMeetingForm.from_params(form_params).with_context(current_user: user) + end + + let(:waitlist_notification) do + { + event: "decidim.events.meetings.meeting_waitlist_added", + event_class: MeetingRegistrationNotificationEvent, + resource: meeting, + affected_users: [user] + } + end + + context "when all conditions are met" do + before do + create_list(:registration, available_slots, meeting: meeting, status: :registered) + end + + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok) + end + + it "creates a waitlist registration with correct attributes" do + expect { subject.call }.to change(Registration, :count).by(1) + last_registration = Registration.last + expect(last_registration.user).to eq(user) + expect(last_registration.meeting).to eq(meeting) + expect(last_registration.status).to eq("on_waiting_list") + end + + it "publishes waitlist notification" do + expect(Decidim::EventsManager).to receive(:publish).with(waitlist_notification) + subject.call + end + end + + context "when waitlist is disabled" do + let(:waitlist_enabled) { false } + + before do + create_list(:registration, available_slots, meeting: meeting, status: :registered) + end + + it "broadcasts invalid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the user is already registered" do + before do + create(:registration, meeting: meeting, user: user, status: :registered) + end + + it "broadcasts invalid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when meeting has available slots" do + it "broadcasts invalid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the form is invalid" do + before do + create_list(:registration, available_slots, meeting: meeting, status: :registered) + allow(form).to receive(:valid?).and_return(false) + end + + it "broadcasts invalid_form" do + expect { subject.call }.to broadcast(:invalid_form) + end + end + end +end diff --git a/decidim-meetings/spec/controllers/decidim/meetings/registrations_controller_spec.rb b/decidim-meetings/spec/controllers/decidim/meetings/registrations_controller_spec.rb new file mode 100644 index 0000000000000..17dc3755d33fb --- /dev/null +++ b/decidim-meetings/spec/controllers/decidim/meetings/registrations_controller_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Meetings + describe RegistrationsController do + routes { Decidim::Meetings::Engine.routes } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :confirmed, organization: organization) } + let(:participatory_process) { create(:participatory_process, organization: organization) } + let(:component) { create(:meeting_component, participatory_space: participatory_process) } + let(:meeting) { create(:meeting, :published, component: component, registrations_enabled: true, available_slots: 10) } + + before do + request.env["decidim.current_organization"] = organization + request.env["decidim.current_participatory_space"] = participatory_process + request.env["decidim.current_component"] = component + end + + describe "POST create" do + let(:params) { { meeting_id: meeting.id } } + + context "when user is authenticated" do + before { sign_in user } + + context "with available slots" do + it "creates registration and redirects" do + expect do + post :create, params: params + end.to change(Registration, :count).by(1) + + expect(flash[:notice]).to eq(I18n.t("registrations.create.success", scope: "decidim.meetings")) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + + context "when no available slots" do + let!(:registrations) { create_list(:registration, 10, meeting: meeting) } + + it "shows error message" do + post :create, params: params + + expect(flash[:alert]).to eq(I18n.t("registrations.create.invalid", scope: "decidim.meetings")) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + end + + context "when user not authenticated" do + it "redirects to login" do + post :create, params: params + expect(response).to redirect_to("/users/sign_in") + end + end + end + + describe "POST answer" do + let(:questionnaire) { create(:questionnaire, :with_questions, questionnaire_for: meeting) } + let(:question) { questionnaire.questions.first } + let(:params) do + { + meeting_id: meeting.id, + waitlist: waitlist, + questionnaire: { + responses: [ + { + body: "Answer", + question_id: question.id + } + ], + tos_agreement: true + } + } + end + let(:waitlist) { false } + + before do + sign_in user + meeting.update!( + registrations_enabled: true, + registration_form_enabled: true, + questionnaire: questionnaire + ) + end + + context "with valid params" do + context "when joining directly" do + it "answers questionnaire and redirects" do + expect do + post :answer, params: params + end.to change { meeting.registrations.count }.by(1) + + expect(flash[:notice]).to eq(I18n.t("registrations.create.success", scope: "decidim.meetings")) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + + context "when joining waitlist" do + let(:meeting) { create(:meeting, component: component, available_slots: 10, waitlist_enabled: true) } + let!(:registrations) { create_list(:registration, 10, meeting: meeting) } + let(:waitlist) { true } + + it "adds user to waitlist and redirects" do + expect do + post :answer, params: params + end.to change { meeting.registrations.where(status: :on_waiting_list).count }.by(1) + + expect(flash[:notice]).to eq(I18n.t("registrations.waitlist.success", scope: "decidim.meetings")) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + end + + context "with invalid params" do + let(:params) do + { + meeting_id: meeting.id, + waitlist: false, + questionnaire: { responses: [] } + } + end + + it "shows error message" do + post :answer, params: params + + expect(flash[:alert]).to eq(I18n.t("answer.invalid", scope: "decidim.forms.questionnaires")) + expect(response).to render_template("decidim/forms/questionnaires/show") + end + end + end + + describe "POST join_waitlist" do + let(:meeting) { create(:meeting, component: component, available_slots: 10, waitlist_enabled: true) } + let!(:registrations) { create_list(:registration, 10, meeting: meeting) } + let(:params) { { meeting_id: meeting.id } } + + before { sign_in user } + + context "when meeting has no available slots" do + it "adds user to waitlist" do + expect do + post :join_waitlist, params: params + end.to change(Registration.on_waiting_list, :count).by(1) + + expect(flash[:notice]).to eq(I18n.t("registrations.waitlist.success", scope: "decidim.meetings")) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + end + + describe "DELETE destroy" do + let!(:registration) { create(:registration, meeting: meeting, user: user) } + let(:params) { { meeting_id: meeting.id } } + + before { sign_in user } + + it "destroys registration" do + expect do + delete :destroy, params: params + end.to change(Registration, :count).by(-1) + + expect(flash[:notice]).to match(/successfully/) + expect(response).to redirect_to(meeting_path(meeting)) + end + end + end +end diff --git a/decidim-meetings/spec/system/meeting_waiting_list_spec.rb b/decidim-meetings/spec/system/meeting_waiting_list_spec.rb new file mode 100644 index 0000000000000..f31b01d536119 --- /dev/null +++ b/decidim-meetings/spec/system/meeting_waiting_list_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Meeting waiting list" do # rubocop:disable RSpec/DescribeClass + include_context "with a component" + let(:manifest_name) { "meetings" } + + let!(:questionnaire) { create(:questionnaire) } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, position: 0) } + let!(:meeting) { create(:meeting, :published, component: component, questionnaire: questionnaire, waitlist_enabled: waitlist_enabled) } + let!(:user) { create(:user, :confirmed, organization: organization) } + let(:registrations_enabled) { true } + let(:registration_form_enabled) { false } + let(:available_slots) { 10 } + let(:waitlist_enabled) { true } + let(:registration_terms) do + { + en: "A legal text", + es: "Un texto legal", + ca: "Un text legal" + } + end + + def visit_meeting + visit resource_locator(meeting).path + end + + before do + meeting.update!( + registrations_enabled: registrations_enabled, + registration_form_enabled: registration_form_enabled, + available_slots: available_slots, + registration_terms: registration_terms + ) + end + + context "when the registration form is enabled" do + let(:registration_form_enabled) { true } + + context "when the waitlist is enabled" do + context "when the meeting has available slots" do + before do + visit_meeting + login_as user, scope: :user + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + end + end + + context "when the meeting has no available slots" do + before do + create_list(:registration, available_slots, meeting: meeting) + end + + context "when the user is not logged in" do + before do + visit_meeting + end + + it "shows the join waitlist button" do + expect(page).to have_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + + it "shows the login modal when clicking the join waitlist button" do + click_on "Join the waiting list" + expect(page).to have_css("#loginModal", visible: :visible) + expect(page).to have_content("Please sign in") + end + end + + context "when the user is logged in" do + before do + login_as user, scope: :user + visit_meeting + end + + it "shows the join waitlist button" do + expect(page).to have_content("Join the waiting list") + end + + it "shows the join waitlist registration form when clicking the join waitlist button" do + click_on "Join the waiting list" + expect(page).to have_i18n_content(questionnaire.title, upcase: true) + expect(page).to have_i18n_content(questionnaire.description, strip_tags: true) + expect(page).to have_i18n_content(question.body) + expect(page).to have_css(".form.answer-questionnaire") + end + + it "can join the waitlist" do + click_on "Join the waiting list" + fill_in question.body["en"], with: "My first answer" + check "questionnaire_tos_agreement" + accept_confirm do + click_on "Submit" + end + expect(page).to have_content("You have joined the waiting list successfully.") + end + end + end + end + + context "when the waitlist is disabled" do + let(:waitlist_enabled) { false } + + context "when the meeting has available slots" do + before do + visit_meeting + login_as user, scope: :user + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + end + + context "when the meeting has no available slots" do + before do + create_list(:registration, available_slots, meeting: meeting) + end + + context "when the user is not logged in" do + before do + visit_meeting + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + end + + context "when the user is logged in" do + before do + login_as user, scope: :user + visit_meeting + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + end + end + end + end + + context "when the registration form is disabled" do + context "when the waitlist is enabled" do + context "when the meeting has available slots" do + before do + visit_meeting + login_as user, scope: :user + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + end + + context "when the meeting has no available slots" do + before do + create_list(:registration, available_slots, meeting: meeting) + login_as user, scope: :user + visit_meeting + end + + it "shows the join waitlist button" do + expect(page).to have_content("Join the waiting list") + expect(page).to have_no_content("You have joined the waiting list for this meeting.") + end + + it "can join the waitlist" do + click_button "Join the waiting list" + click_button "Confirm" + expect(page).to have_content("You have joined the waiting list successfully.") + expect(page).to have_content("You have joined the waiting list for this meeting.") + end + end + end + + context "when the waitlist is disabled" do + let(:waitlist_enabled) { false } + + context "when the meeting has available slots" do + before do + visit_meeting + login_as user, scope: :user + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + end + end + + context "when the meeting has no available slots" do + before do + create_list(:registration, available_slots, meeting: meeting) + login_as user, scope: :user + visit_meeting + end + + it "does not show the join waitlist button" do + expect(page).to have_no_content("Join the waiting list") + end + end + end + end + + def leave_meeting(user) + login_as user, scope: :user + visit_meeting + click_on "Cancel your registration" + logout :user + end + + context "when the meeting is full and a user cancels their registration" do + context "and there are users on the waiting list" do + let!(:registrations) { create_list(:registration, available_slots, meeting: meeting) } + let(:users_on_waitlist) { create_list(:user, 5, :confirmed, organization: organization) } + let(:first_waitlist_user) { users_on_waitlist.first } + + let!(:waitlist_entries) do + users_on_waitlist.map.with_index do |user, index| + create(:registration, meeting: meeting, user: user, status: "on_waiting_list", created_at: Time.current - index.minutes) + end + end + + let(:earliest_waitlist_entry) { waitlist_entries.min_by(&:created_at) } + let(:earliest_waitlist_user) { earliest_waitlist_entry.user } + + before do + perform_enqueued_jobs { Decidim::Meetings::LeaveMeeting.call(meeting, registrations.first.user) } + login_as earliest_waitlist_user, scope: :user + end + + it "displays the registration confirmation" do + visit_meeting + email = last_email + expect(page).to have_content("Your registration code") + expect(email.subject).to eq("Your meeting's registration has been confirmed") + expect(email.to).to eq([earliest_waitlist_user.email]) + end + end + end +end