diff --git a/AGENTS.md b/AGENTS.md index 05033a0e9..e3cd2a1fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ AWBW Portal (Rails 8.1) - **Root level** (~48 controllers): Workshops, stories, resources, events, people, organizations, etc. - **`admin/`**: HomeController, AnalyticsController, AhoyActivitiesController - **`api/v1/`**: ApiController base, Authentications, Workshops, Quotes, Resources -- **`events/`**: Registrations sub-resource +- **`events/`**: Registrations sub-resource (create/destroy + slug-based show at `/registration/:slug`) - **Devise overrides**: Registrations, Confirmations, Passwords ### Base Controller Pattern diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 7559d42b7..442bc7aad 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -1,7 +1,7 @@ class EventRegistrationsController < ApplicationController require "csv" - skip_before_action :authenticate_user!, only: [ :show ] + # show redirects to slug URL; kept for backwards compatibility before_action :set_event_registration, only: [ :show, :edit, :update, :destroy ] def index @@ -30,6 +30,7 @@ def index def show authorize! @event_registration + redirect_to registration_ticket_path(@event_registration.slug), status: :moved_permanently end def new @@ -67,7 +68,7 @@ def create redirect_to manage_event_path(@event_registration.event), notice: "Registration created." else - redirect_to @event_registration, + redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration created." end } @@ -100,7 +101,7 @@ def update if params[:return_to] == "manage" redirect_to manage_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other else - redirect_to @event_registration, notice: "Registration was successfully updated.", status: :see_other + redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other end } end diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 886d0f4a3..3207796d1 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -50,7 +50,7 @@ def create ) if result.success? - redirect_to event_registration_path(result.event_registration), + redirect_to registration_ticket_path(result.event_registration.slug), notice: "You have been successfully registered!" else @form_fields = @form.form_fields.where(status: :active).reorder(position: :asc) @@ -63,14 +63,15 @@ def create def show authorize! :public_registration, to: :show? + registration = EventRegistration.find_by!(slug: params[:reg], event_id: @event.id) + @form = registration_form unless @form redirect_to event_path(@event), alert: "Registration form not found." return end - - @person_form = @form.person_forms.find_by(person: params[:person_id]) + @person_form = @form.person_forms.find_by(person: registration.registrant) unless @person_form redirect_to event_path(@event), alert: "No registration form submission found." return diff --git a/app/controllers/events/registrations_controller.rb b/app/controllers/events/registrations_controller.rb index 313efec4a..becfb6aec 100644 --- a/app/controllers/events/registrations_controller.rb +++ b/app/controllers/events/registrations_controller.rb @@ -1,8 +1,41 @@ module Events class RegistrationsController < ApplicationController - before_action :authenticate_user! - before_action :set_event - before_action :set_registrant + before_action :authenticate_user!, only: [ :create, :destroy ] + before_action :set_event, only: [ :create, :destroy ] + before_action :set_registrant, only: [ :create, :destroy ] + before_action :set_event_registration, only: [ :show, :resend_confirmation, :cancel, :reactivate ] + + def show + authorize! @event_registration, to: :show_public? + end + + def resend_confirmation + authorize! @event_registration, to: :show_public? + EventMailer.event_registration_confirmation(@event_registration).deliver_later + redirect_to registration_ticket_path(@event_registration.slug), notice: "Confirmation email sent." + end + + def cancel + authorize! @event_registration, to: :show_public? + + if @event_registration.active? + @event_registration.update!(status: "cancelled") + redirect_to registration_ticket_path(@event_registration.slug), notice: "Your registration has been cancelled." + else + redirect_to registration_ticket_path(@event_registration.slug), alert: "Registration is already cancelled." + end + end + + def reactivate + authorize! @event_registration, to: :show_public? + + if @event_registration.status == "cancelled" + @event_registration.update!(status: "registered") + redirect_to registration_ticket_path(@event_registration.slug), notice: "Your registration has been reactivated." + else + redirect_to registration_ticket_path(@event_registration.slug), alert: "Registration is not cancelled." + end + end def create @event_registration = @event.event_registrations.new(registrant: @registrant) @@ -12,7 +45,7 @@ def create success = "You have successfully registered for this event." respond_to do |format| format.turbo_stream { flash.now[:notice] = success } - format.html { redirect_to @event_registration, notice: success } + format.html { redirect_to registration_ticket_path(@event_registration.slug), notice: success } end else error = @event_registration.errors.full_messages.to_sentence @@ -78,5 +111,9 @@ def create_person_for_current_user current_user.update!(person: person) person end + + def set_event_registration + @event_registration = EventRegistration.find_by!(slug: params[:slug]) + end end end diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 3d1aec85e..e46f6c7e3 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -6,6 +6,10 @@ def title def detail(length: nil) end + def link_target + h.registration_ticket_path(slug) + end + def default_display_image return event.primary_asset.file if event.respond_to?(:primary_asset) && event.primary_asset&.file&.attached? "theme_default.png" diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index 23880e3db..988e1b59a 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -7,7 +7,7 @@ def event_registration_confirmation(event_registration) @notification_type = "Event registration confirmation" @time_zone = @person.user&.time_zone || Time.zone.name - @event_url = event_url(@event) + @event_url = event_url(@event, reg: @event_registration.slug) @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index afaf7912b..dc38b6311 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -9,6 +9,8 @@ class EventRegistration < ApplicationRecord accepts_nested_attributes_for :comments, reject_if: proc { |attrs| attrs["body"].blank? } + before_create :generate_slug + ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze INACTIVE_STATUSES = %w[ cancelled no_show ].freeze ATTENDANCE_STATUSES = (ACTIVE_STATUSES + INACTIVE_STATUSES).freeze @@ -17,6 +19,7 @@ class EventRegistration < ApplicationRecord validates :registrant_id, uniqueness: { scope: :event_id } validates :event_id, presence: true validates :status, inclusion: { in: ATTENDANCE_STATUSES }, allow_nil: false + validates :slug, uniqueness: true, allow_nil: true # Scopes scope :registrant_name, ->(registrant_name) { joins(:registrant).where( @@ -136,4 +139,11 @@ def create_refund_payments currency: "usd" ) end + + def generate_slug + loop do + self.slug = SecureRandom.urlsafe_base64(16) + break unless EventRegistration.exists?(slug: slug) + end + end end diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index 1e8b39a3c..eb55d62bb 100644 --- a/app/policies/event_registration_policy.rb +++ b/app/policies/event_registration_policy.rb @@ -7,7 +7,8 @@ def index? = admin? def create? = admin? || owner? def update? = admin? || owner? def destroy? = record.persisted? && (admin? || owner?) - def show? = true + def show? = admin? + def show_public? = true relation_scope do |relation| diff --git a/app/services/event_registration_form_builder.rb b/app/services/event_registration_form_builder.rb index a8b59c617..591fe6ff2 100644 --- a/app/services/event_registration_form_builder.rb +++ b/app/services/event_registration_form_builder.rb @@ -155,6 +155,9 @@ def build_qualitative_fields(form, position) key: "referral_source", group: "qualitative", required: false) position = add_field(form, position, "What motivates you to attend this training?", :free_form_input_paragraph, key: "training_motivation", group: "qualitative", required: false) + position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?", :multiple_choice_radio, + key: "interested_in_more", group: "qualitative", required: true, + options: %w[Yes No]) position end diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index 93e9bc33a..7fc392cdd 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -65,7 +65,7 @@

<% if @event_registration.persisted? %> - View registration + View registration <% end %> View event

\ No newline at end of file diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index 474a1728e..bcbe8a3fa 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -25,6 +25,9 @@ Videoconference URL: <%= @event.videoconference_url %> <%= @event.detail %> <% end %> +View your registration: +<%= registration_ticket_url(@event_registration.slug) %> + View the event page: <%= @event_url %> diff --git a/app/views/event_registrations/show.html.erb b/app/views/event_registrations/_ticket.html.erb similarity index 51% rename from app/views/event_registrations/show.html.erb rename to app/views/event_registrations/_ticket.html.erb index 7b339b2e7..ffc289594 100644 --- a/app/views/event_registrations/show.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -1,12 +1,5 @@ -<% content_for(:page_bg_class, "admin-or-owner") %> -
- <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <% if allowed_to?(:index?, EventRegistration) %> - <%= link_to "Manage Registrants", manage_event_path(@event_registration.event), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <% end %> -
- <%= link_to "← Back to Event", event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block" %> + <%= link_to "← Back to Event", event_path(event_registration.event, reg: event_registration.slug), class: "text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block" %>
@@ -42,19 +35,19 @@
<%= render "assets/display_image", - resource: @event_registration.event, + resource: event_registration.event, width: 48, height: 32, variant: :index, - link_to_object: true, - file: @event_registration.event.decorate.display_image %> + link: event_path(event_registration.event, reg: event_registration.slug), + file: event_registration.event.decorate.display_image %>
- <% if @event_registration.event.respond_to?(:pre_title) && @event_registration.event.pre_title.present? %> -

<%= @event_registration.event.pre_title %>

+ <% if event_registration.event.respond_to?(:pre_title) && event_registration.event.pre_title.present? %> +

<%= event_registration.event.pre_title %>

<% end %>

- <%= link_to @event_registration.event.title, event_path(@event_registration.event), class: "hover:underline" %> + <%= link_to event_registration.event.title, event_path(event_registration.event, reg: event_registration.slug), class: "hover:underline" %>

@@ -71,9 +64,9 @@

Registrant

<% if user_signed_in? %> - <%= link_to @event_registration.registrant.full_name, person_path(@event_registration.registrant), class: "text-blue-700 hover:text-blue-900 underline" %> + <%= link_to event_registration.registrant.full_name, person_path(event_registration.registrant), class: "text-blue-700 hover:text-blue-900 underline" %> <% else %> - <%= @event_registration.registrant.full_name %> + <%= event_registration.registrant.full_name %> <% end %>

@@ -82,33 +75,33 @@
- <%= @event_registration.event.decorate.times(display_day: true, display_date: true, styled: true) %> + <%= event_registration.event.decorate.times(display_day: true, display_date: true, styled: true) %>
- <% if @event_registration.event.decorate.labelled_cost.present? %> + <% if event_registration.event.decorate.labelled_cost.present? %>
- <%= @event_registration.event.decorate.labelled_cost %> + <%= event_registration.event.decorate.labelled_cost %>
<% end %> - <% if @event_registration.event.location.present? %> + <% if event_registration.event.location.present? %>
- <%= @event_registration.event.location.name %> + <%= event_registration.event.location.name %>
<% end %> - <% if @event_registration.event.autoshow_videoconference_label && @event_registration.event.videoconference_label.present? %> -
<%= @event_registration.event.videoconference_label %>
+ <% if event_registration.event.autoshow_videoconference_label && event_registration.event.videoconference_label.present? %> +
<%= event_registration.event.videoconference_label %>
<% end %> - <%= render "events/videoconference_link", event: @event_registration.event.decorate, joinable: @event_registration.joinable? %> + <%= render "events/videoconference_link", event: event_registration.event.decorate, joinable: event_registration.joinable? %>
- <% if @event_registration.event.cost_cents.to_i > 0 && !@event_registration.paid? %> - <% paid_cents = @event_registration.payments.successful.sum(:amount_cents) %> - <% due_cents = @event_registration.event.cost_cents - paid_cents %> + <% if event_registration.event.cost_cents.to_i > 0 && !event_registration.paid? %> + <% paid_cents = event_registration.payments.successful.sum(:amount_cents) %> + <% due_cents = event_registration.event.cost_cents - paid_cents %>
$<%= "%.2f" % (due_cents / 100.0) %> payment is due @@ -124,29 +117,26 @@
- <% if @event_registration.checked_in? %> + <% if event_registration.checked_in? %> Checked In - <% elsif @event_registration.respond_to?(:canceled?) && @event_registration.canceled? %> + <% elsif event_registration.status == "cancelled" %> - Canceled + Registration cancelled + <% if event_registration.event.registerable? %> +
+ <%= button_to "Register again", registration_reactivate_path(event_registration.slug), + class: "text-sm text-blue-700 hover:text-blue-900 underline bg-transparent border-0 cursor-pointer p-0" %> +
+ <% end %> <% else %> - Registered on <%= @event_registration.created_at.strftime("%B %-d, %Y") %> + Registered on <%= event_registration.created_at.strftime("%B %-d, %Y") %> <% end %> - <% if user_signed_in? %> -
- <%= button_to "De-register", - event_registrant_registration_path(event_id: @event_registration.event), - method: :delete, - data: { turbo_confirm: "Are you sure?" }, - class: "btn btn-secondary-outline text-sm" %> -
- <% end %>
@@ -155,18 +145,27 @@ Add to Your Calendar

- <%= @event_registration.event.decorate.calendar_links %> + <%= event_registration.event.decorate.calendar_links %>
- <% registration_form = @event_registration.event.forms.find_by(name: "Public Registration") %> - <% if registration_form && registration_form.person_forms.exists?(person: @event_registration.registrant) %> -
- <%= link_to "View Registration Form", - event_public_registration_path(@event_registration.event, person_id: @event_registration.registrant&.id), - class: "text-sm text-blue-700 hover:text-blue-900 underline" %> -
- <% end %> +
+ <% registration_form = event_registration.event.forms.find_by(name: "Public Registration") %> + <% if registration_form && registration_form.person_forms.exists?(person: event_registration.registrant) %> + <%= link_to "View registration form", + event_public_registration_path(event_registration.event, reg: event_registration.slug), + class: "text-xs text-gray-400 hover:text-blue-600 underline" %> + <% end %> + <%= button_to "Resend confirmation email", + registration_resend_confirmation_path(event_registration.slug), + class: "text-xs text-gray-400 hover:text-blue-600 underline bg-transparent border-0 cursor-pointer p-0" %> + <% if event_registration.active? %> + <%= button_to "Cancel registration", + registration_cancel_path(event_registration.slug), + data: { turbo_confirm: "Are you sure you want to cancel your registration?" }, + class: "text-xs text-gray-400 hover:text-red-600 underline bg-transparent border-0 cursor-pointer p-0" %> + <% end %> +
diff --git a/app/views/events/_registration_section.html.erb b/app/views/events/_registration_section.html.erb index 649523110..29b18c98c 100644 --- a/app/views/events/_registration_section.html.erb +++ b/app/views/events/_registration_section.html.erb @@ -1,6 +1,9 @@ <% instance ||= 1 %> <% button_text ||= "Register" %> <% registered = event.actively_registered?(current_user&.person) %> +<% slug_registration = params[:reg].present? ? EventRegistration.find_by(slug: params[:reg], event_id: event.id) : nil %> +<% slug_registered = slug_registration&.active? %> +<% slug_cancelled = slug_registration.present? && slug_registration.status == "cancelled" %> <%= tag.div id: dom_id(event.object, "registration_section_#{instance}"), class: "registration-section flex flex-col items-center gap-4 mb-6" do %>
@@ -12,40 +15,41 @@ class: "admin-only bg-blue-100 btn btn-primary-outline" %> <% end %> <% elsif registered %> - <%= button_to "De-register", - event_registrant_registration_path(event_id: event), - method: :delete, - data: { turbo_confirm: "Are you sure?" }, - class: "btn px-10 py-2 text-2xl uppercase bg-white event-deregister-btn", - style: "font-family: 'Telefon Bold', sans-serif; color: rgb(170, 46, 0); border: 3px solid rgb(170, 46, 0);" %> - <% elsif !event.published? %> - Not published - <% elsif event.registerable? %> - <% if user_signed_in? %> - <%= button_to button_text, - event_registrant_registration_path(event_id: event), - class: "btn px-10 py-2 text-2xl uppercase text-white hover:bg-white event-register-btn", - style: "font-family: 'Telefon Bold', sans-serif; background-color: rgb(170, 46, 0); border: 3px solid rgb(170, 46, 0);" %> - <% elsif event.object.forms.exists?(name: "Public Registration") %> - <%= link_to button_text, - new_event_public_registration_path(event), + <% elsif slug_cancelled && event.registerable? %> + <%= button_to "Register again", registration_reactivate_path(slug_registration.slug), class: "btn px-10 py-2 text-2xl uppercase text-white hover:bg-white event-register-btn", style: "font-family: 'Telefon Bold', sans-serif; background-color: rgb(170, 46, 0); border: 3px solid rgb(170, 46, 0);" %> + <% elsif !registered && !slug_registered %> + <% if !event.published? %> + Not published + <% elsif event.registerable? %> + <% if user_signed_in? %> + <%= button_to button_text, + event_registrant_registration_path(event_id: event), + class: "btn px-10 py-2 text-2xl uppercase text-white hover:bg-white event-register-btn", + style: "font-family: 'Telefon Bold', sans-serif; background-color: rgb(170, 46, 0); border: 3px solid rgb(170, 46, 0);" %> + <% elsif event.object.forms.exists?(name: "Public Registration") %> + <%= link_to button_text, + new_event_public_registration_path(event), + class: "btn px-10 py-2 text-2xl uppercase text-white hover:bg-white event-register-btn", + style: "font-family: 'Telefon Bold', sans-serif; background-color: rgb(170, 46, 0); border: 3px solid rgb(170, 46, 0);" %> + <% end %> + <% else %> + Registration closed <% end %> - <% else %> - Registration closed <% end %> <% if registered %> <% registration = event.active_registration_for(current_user&.person) %> - <%= link_to event_registration_path(registration), - class: "text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full hover:bg-green-200" do %> - You are registered! - <% end %> + <%= link_to "View your registration", registration_ticket_path(registration.slug), + class: "text-sm text-green-700 hover:text-green-900 underline" %> + <% elsif slug_registered %> + <%= link_to "View your registration", registration_ticket_path(slug_registration.slug), + class: "text-sm text-green-700 hover:text-green-900 underline" %> <% end %>
- <% if registered %> + <% if registered || slug_registered %>

diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 79f19d1d6..d5fe9730f 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -67,7 +67,7 @@ name="<%= field_name %>" value="<%= ffao.answer_option.name %>" class="text-blue-600 focus:ring-blue-500" - <%= "checked" if value == ffao.answer_option.name %> + <%= "checked" if value == ffao.answer_option.name || (value.blank? && field.field_key == "interested_in_more" && ffao.answer_option.name == "Yes") %> <%= "required" if field.is_required %>> <%= ffao.answer_option.name %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index fe970f472..de4b85c79 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -1,10 +1,11 @@ -<% content_for(:page_bg_class, "admin-or-owner") %> +<% content_for(:page_bg_class, "public") %>

<%# ---- BACK LINK ---- %>
- <%= link_to event_registration_path(@person_form.person.event_registrations.find_by(event: @event)), + <% reg = @person_form.person.event_registrations.find_by(event: @event) %> + <%= link_to registration_ticket_path(reg.slug), class: "text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1" do %> Back to Registration <% end %> diff --git a/app/views/events/registrations/show.html.erb b/app/views/events/registrations/show.html.erb new file mode 100644 index 000000000..d4a94cb55 --- /dev/null +++ b/app/views/events/registrations/show.html.erb @@ -0,0 +1,8 @@ +<% content_for(:page_bg_class, "public") %> +
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:index?, EventRegistration) %> + <%= link_to "Manage Registrants", manage_event_path(@event_registration.event), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> +
+<%= render "event_registrations/ticket", event_registration: @event_registration %> diff --git a/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb b/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb index c28d502ad..a2891abef 100644 --- a/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb +++ b/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb @@ -52,7 +52,7 @@

<%= link_to "View registration", - event_registration_url(@event_registration), + registration_ticket_url(@event_registration.slug), style: "display:inline-block; padding:10px 16px; background:#2563eb; @@ -69,7 +69,7 @@ - diff --git a/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb b/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb index 4eed1e0ea..c35fb83d9 100644 --- a/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb +++ b/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb @@ -36,11 +36,11 @@ Event date ------------------------------------------------------------ View registration: -<%= event_registration_url(@event_registration) if @event_registration.present? %> +<%= registration_ticket_url(@event_registration.slug) if @event_registration.present? %> View profile: <%= profile_url %> View event: -<%= event_url(@event) %> +<%= event_url(@event, reg: @event_registration.slug) %> diff --git a/app/views/people/_show_card.html.erb b/app/views/people/_show_card.html.erb index 3e0d84ad0..c3924e1f8 100644 --- a/app/views/people/_show_card.html.erb +++ b/app/views/people/_show_card.html.erb @@ -23,7 +23,7 @@ border-b md:border-b-0 md:border-r border-gray-200">

- <%= link_to polymorphic_path(record.object), data: { turbo_frame: "_top"}, class: "block w-full h-full" do %> + <%= link_to record.link_target, data: { turbo_frame: "_top"}, class: "block w-full h-full" do %> <%= render "assets/display_image", resource: record.object, width: "full", height: "full", @@ -37,7 +37,7 @@
- <%= link_to polymorphic_path(record.object), + <%= link_to record.link_target, data: { turbo_frame: "_top"}, class: "inline-flex min-w-0 max-w-full text-lg font-semibold text-gray-900 leading-tight hover:underline" do %> diff --git a/config/routes.rb b/config/routes.rb index 0c7b7680f..92f2b8e8c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,10 @@ end end resources :community_news + get "registration/:slug", to: "events/registrations#show", as: :registration_ticket + post "registration/:slug/resend_confirmation", to: "events/registrations#resend_confirmation", as: :registration_resend_confirmation + post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel + post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate resources :event_registrations do resources :comments, only: [ :index, :create ] end diff --git a/db/migrate/20260228230836_add_slug_to_event_registrations.rb b/db/migrate/20260228230836_add_slug_to_event_registrations.rb new file mode 100644 index 000000000..4f48248de --- /dev/null +++ b/db/migrate/20260228230836_add_slug_to_event_registrations.rb @@ -0,0 +1,6 @@ +class AddSlugToEventRegistrations < ActiveRecord::Migration[8.0] + def change + add_column :event_registrations, :slug, :string unless column_exists?(:event_registrations, :slug) + add_index :event_registrations, :slug, unique: true unless index_exists?(:event_registrations, :slug) + end +end diff --git a/db/schema.rb b/db/schema.rb index b431428f4..fc0659cb1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_28_134201) do +ActiveRecord::Schema[8.1].define(version: 2026_02_28_230836) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -403,11 +403,13 @@ t.bigint "registrant_id", null: false t.boolean "scholarship_recipient", default: false, null: false t.boolean "scholarship_tasks_completed", default: false, null: false + t.string "slug" t.string "status", default: "registered", null: false t.datetime "updated_at", null: false t.index ["event_id"], name: "index_event_registrations_on_event_id" t.index ["registrant_id", "event_id"], name: "index_event_registrations_on_registrant_id_and_event_id", unique: true t.index ["registrant_id"], name: "index_event_registrations_on_registrant_id" + t.index ["slug"], name: "index_event_registrations_on_slug", unique: true end create_table "events", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -812,7 +814,6 @@ t.index ["organization_id"], name: "index_reports_on_organization_id" t.index ["owner_type", "owner_id"], name: "index_reports_on_owner_type_and_owner_id" t.index ["type", "date"], name: "index_reports_on_type_and_date" - t.index ["type", "organization_id"], name: "index_reports_on_type_and_organization_id" t.index ["windows_type_id"], name: "index_reports_on_windows_type_id" t.index ["workshop_id"], name: "index_reports_on_workshop_id" end diff --git a/lib/tasks/backfill_event_registration_slugs.rake b/lib/tasks/backfill_event_registration_slugs.rake new file mode 100644 index 000000000..c677f8bd6 --- /dev/null +++ b/lib/tasks/backfill_event_registration_slugs.rake @@ -0,0 +1,11 @@ +namespace :event_registrations do + desc "Backfill slugs for existing event registrations" + task backfill_slugs: :environment do + count = 0 + EventRegistration.where(slug: nil).find_each do |registration| + registration.update_column(:slug, SecureRandom.urlsafe_base64(16)) + count += 1 + end + puts "Backfilled #{count} event registration slugs" + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index cbedb301e..159d644f1 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -227,4 +227,24 @@ expect(results).not_to include(reg_bob_music) end end + + describe "slug" do + it "generates a slug on create" do + registration = create(:event_registration) + expect(registration.slug).to be_present + expect(registration.slug.length).to eq(22) + end + + it "does not change slug on update" do + registration = create(:event_registration) + original_slug = registration.slug + registration.update!(status: "attended") + expect(registration.reload.slug).to eq(original_slug) + end + + it "generates unique slugs" do + slugs = 10.times.map { create(:event_registration).slug } + expect(slugs.uniq.size).to eq(10) + end + end end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 69299c400..0dd9dff61 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -78,7 +78,7 @@ patch event_registration_path(existing_registration), params: { event_registration: { event_id: new_event.id } } - expect(response).to redirect_to(event_registration_path(existing_registration)) + expect(response).to redirect_to(registration_ticket_path(existing_registration.slug)) expect(existing_registration.reload.event_id).to eq(new_event.id) end end @@ -109,6 +109,13 @@ end end + describe "GET /event_registrations/:id (admin-only show)" do + it "redirects to root (unauthorized)" do + get event_registration_path(existing_registration) + expect(response).to redirect_to(root_path) + end + end + describe "POST /event_registrations" do context "when no registration exists yet" do it "creates a new EventRegistration" do @@ -157,7 +164,7 @@ params: { event_registration: { status: "cancelled" } } expect(existing_registration.reload.status).to eq("cancelled") - expect(response).to redirect_to(event_registration_path(existing_registration)) + expect(response).to redirect_to(registration_ticket_path(existing_registration.slug)) expect(flash[:notice]).to eq("Registration was successfully updated.") end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 6c7c8d5f6..7a246256f 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -8,6 +8,179 @@ let(:turbo_headers) { { "Accept" => "text/vnd.turbo-stream.html" } } + describe "GET /registration/:slug" do + let(:admin) { create(:user, :with_person, super_user: true) } + let(:other_user) { create(:user, :with_person) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + context "as the registrant (owner)" do + it "shows the registration ticket" do + get registration_ticket_path(registration.slug) + expect(response).to have_http_status(:success) + end + end + + context "as an admin" do + before { sign_in admin } + + it "shows the registration ticket" do + get registration_ticket_path(registration.slug) + expect(response).to have_http_status(:success) + end + end + + context "as another user" do + before { sign_in other_user } + + it "shows the registration ticket (slug is authorization)" do + get registration_ticket_path(registration.slug) + expect(response).to have_http_status(:success) + end + end + + context "as a guest" do + before { sign_out user } + + it "shows the registration ticket (slug is authorization)" do + get registration_ticket_path(registration.slug) + expect(response).to have_http_status(:success) + end + end + + context "with an invalid slug" do + it "returns 404" do + get registration_ticket_path("nonexistent-slug") + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /registration/:slug/resend_confirmation" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "sends confirmation email and redirects back" do + expect { + post registration_resend_confirmation_path(registration.slug) + }.to have_enqueued_mail(EventMailer, :event_registration_confirmation) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + expect(flash[:notice]).to eq("Confirmation email sent.") + end + + context "as a guest" do + before { sign_out user } + + it "sends confirmation email (slug is authorization)" do + expect { + post registration_resend_confirmation_path(registration.slug) + }.to have_enqueued_mail(EventMailer, :event_registration_confirmation) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + end + end + + describe "POST /registration/:slug/cancel" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person, status: "registered") } + + it "cancels an active registration" do + post registration_cancel_path(registration.slug) + + expect(registration.reload.status).to eq("cancelled") + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + expect(flash[:notice]).to eq("Your registration has been cancelled.") + end + + it "does not cancel an already cancelled registration" do + registration.update!(status: "cancelled") + + post registration_cancel_path(registration.slug) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + expect(flash[:alert]).to eq("Registration is already cancelled.") + end + + context "as a guest" do + before { sign_out user } + + it "cancels the registration (slug is authorization)" do + post registration_cancel_path(registration.slug) + + expect(registration.reload.status).to eq("cancelled") + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + end + end + + describe "POST /registration/:slug/reactivate" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person, status: "cancelled") } + + it "reactivates a cancelled registration" do + post registration_reactivate_path(registration.slug) + + expect(registration.reload.status).to eq("registered") + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + expect(flash[:notice]).to eq("Your registration has been reactivated.") + end + + it "does not reactivate an already active registration" do + registration.update!(status: "registered") + + post registration_reactivate_path(registration.slug) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + expect(flash[:alert]).to eq("Registration is not cancelled.") + end + + context "as a guest" do + before { sign_out user } + + it "reactivates the registration (slug is authorization)" do + post registration_reactivate_path(registration.slug) + + expect(registration.reload.status).to eq("registered") + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + end + end + + describe "GET /events/:event_id/public_registration (show)" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + before do + EventRegistrationFormBuilder.build!(event) + form = event.forms.find_by(name: "Public Registration") + form.person_forms.create!(person: user.person) + end + + it "allows access with a valid slug" do + get event_public_registration_path(event, reg: registration.slug) + expect(response).to have_http_status(:success) + end + + it "returns 404 with an invalid slug" do + get event_public_registration_path(event, reg: "bogus-slug") + expect(response).to have_http_status(:not_found) + end + + it "returns 404 with a slug from a different event" do + other_event = create(:event) + other_registration = create(:event_registration, event: other_event, registrant: user.person) + + get event_public_registration_path(event, reg: other_registration.slug) + expect(response).to have_http_status(:not_found) + end + + context "as a guest" do + before { sign_out user } + + it "allows access with a valid slug" do + get event_public_registration_path(event, reg: registration.slug) + expect(response).to have_http_status(:success) + end + end + end + describe "POST /events/:event_id/registrations" do context "when successful" do it "creates a registration and returns turbo stream" do diff --git a/spec/system/event_registration_show_spec.rb b/spec/system/event_registration_show_spec.rb index 171f353b1..8903fc5a8 100644 --- a/spec/system/event_registration_show_spec.rb +++ b/spec/system/event_registration_show_spec.rb @@ -18,21 +18,21 @@ before { driven_by(:rack_test) } describe "back to event link" do - it "shows a back link to the event page" do + it "shows a back link to the event page with reg param" do sign_in(user) - visit event_registration_path(registration) + visit registration_ticket_path(registration.slug) - expect(page).to have_link("Back to Event", href: event_path(event)) + expect(page).to have_link("Back to Event", href: event_path(event, reg: registration.slug)) end end describe "event title link" do - it "links the event title back to the event page" do + it "links the event title back to the event page with reg param" do sign_in(user) - visit event_registration_path(registration) + visit registration_ticket_path(registration.slug) within("h2") do - expect(page).to have_link(event.title, href: event_path(event)) + expect(page).to have_link(event.title, href: event_path(event, reg: registration.slug)) end end end @@ -40,11 +40,76 @@ describe "calendar links" do it "shows Add to Your Calendar header and calendar links" do sign_in(user) - visit event_registration_path(registration) + visit registration_ticket_path(registration.slug) expect(page).to have_text("Add to Your Calendar") expect(page).to have_link("Google") expect(page).to have_link("Office 365") end end + + describe "view registration form link" do + it "links to form show with slug param" do + EventRegistrationFormBuilder.build!(event) + form = event.forms.find_by(name: "Public Registration") + form.person_forms.create!(person: user.person) + + sign_in(user) + visit registration_ticket_path(registration.slug) + + expect(page).to have_link("View registration form", + href: event_public_registration_path(event, reg: registration.slug)) + end + end + + describe "action links" do + it "shows resend and cancel for active registration" do + sign_in(user) + visit registration_ticket_path(registration.slug) + + expect(page).to have_button("Resend confirmation email") + expect(page).to have_button("Cancel registration") + end + + it "shows resend but not cancel for cancelled registration" do + registration.update!(status: "cancelled") + + sign_in(user) + visit registration_ticket_path(registration.slug) + + expect(page).to have_button("Resend confirmation email") + expect(page).not_to have_button("Cancel registration") + end + end + + describe "cancelled registration" do + before { registration.update!(status: "cancelled") } + + it "shows Registration cancelled badge and Register again button" do + sign_in(user) + visit registration_ticket_path(registration.slug) + + expect(page).to have_text("Registration cancelled") + expect(page).to have_button("Register again") + end + + it "does not show Register again when registration is closed" do + event.update!(registration_close_date: 1.day.ago) + + sign_in(user) + visit registration_ticket_path(registration.slug) + + expect(page).to have_text("Registration cancelled") + expect(page).not_to have_button("Register again") + end + end + + describe "guest access" do + it "allows guests to view the ticket via slug" do + visit registration_ticket_path(registration.slug) + + expect(page).to have_text("Event Registration") + expect(page).to have_text(registration.registrant.full_name) + end + end end diff --git a/spec/system/events_show_spec.rb b/spec/system/events_show_spec.rb index fbed566c1..5c574d28d 100644 --- a/spec/system/events_show_spec.rb +++ b/spec/system/events_show_spec.rb @@ -130,8 +130,7 @@ visit event_path(event) expect(page).to have_button("Register") - expect(page).not_to have_button("De-register") - expect(page).not_to have_text("You are registered!") + expect(page).not_to have_text("View your registration") end end @@ -140,15 +139,15 @@ create(:event_registration, event: event, registrant: user.person) end - it "shows deregister button, badge, and calendar links" do + it "shows registration link and calendar links" do sign_in(user) visit event_path(event) - expect(page).to have_button("De-register") - expect(page).to have_text("You are registered!") + expect(page).to have_text("View your registration") expect(page).to have_text("Add to Your Calendar") expect(page).to have_text("Google") expect(page).to have_text("Office 365") + expect(page).not_to have_button("Register") end end @@ -181,6 +180,58 @@ end end + context "guest with reg slug param" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "shows 'View your registration' badge and calendar links" do + visit event_path(event, reg: registration.slug) + + expect(page).to have_text("View your registration") + expect(page).to have_text("Add to Your Calendar") + expect(page).not_to have_button("Register") + end + + it "does not show badge with invalid slug" do + visit event_path(event, reg: "bogus-slug") + + expect(page).not_to have_text("View your registration") + end + + it "does not show badge with slug from a different event" do + other_event = create(:event, :published, :publicly_visible) + other_registration = create(:event_registration, event: other_event, registrant: user.person) + + visit event_path(event, reg: other_registration.slug) + + expect(page).not_to have_text("View your registration") + end + end + + context "guest with cancelled registration slug" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person, status: "cancelled") } + + it "does not show 'View your registration' link" do + visit event_path(event, reg: registration.slug) + + expect(page).not_to have_text("View your registration") + end + + it "shows 'Register again' button" do + visit event_path(event, reg: registration.slug) + + expect(page).to have_button("Register again") + end + + it "does not show 'Register again' when registration is closed" do + event.update!(registration_close_date: 1.day.ago) + + visit event_path(event, reg: registration.slug) + + expect(page).not_to have_button("Register again") + expect(page).to have_text("Registration closed") + end + end + context "unpublished event with future registration date" do before do event.update!(published: false, publicly_visible: false) @@ -358,40 +409,22 @@ describe "registration button updates via Turbo", js: true do before { driven_by(:selenium_chrome_headless) } - it "updates Register to De-register and shows clickable registration link without full page reload" do + it "updates Register to show registration link without full page reload" do sign_in(user) visit event_path(event) expect(page).to have_button("Register") - expect(page).not_to have_text("You are registered!") + expect(page).not_to have_text("View your registration") click_button "Register" # Turbo stream replaces the registration section; we stay on the event page expect(page).to have_current_path(event_path(event)) - expect(page).to have_button("De-register") expect(page).not_to have_button("Register") - # "You are registered!" is a clickable link to the registration show page + # "View your registration" is a clickable link to the registration show page registration = EventRegistration.last - expect(page).to have_link("You are registered!", href: event_registration_path(registration)) - end - - it "updates De-register back to Register after de-registering" do - create(:event_registration, event: event, registrant: user.person) - - sign_in(user) - visit event_path(event) - - expect(page).to have_button("De-register") - accept_confirm do - click_button "De-register" - end - - expect(page).to have_current_path(event_path(event)) - expect(page).to have_button("Register") - expect(page).not_to have_button("De-register") - expect(page).not_to have_text("You are registered!") + expect(page).to have_link("View your registration", href: registration_ticket_path(registration.slug)) end end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 9df21a35a..c9383d202 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -63,7 +63,6 @@ "app/views/organizations/populations_served.html.erb" => "admin-or-authpublished", # ─── admin-or-owner (policy: admin? || owner?) ─── - "app/views/event_registrations/show.html.erb" => "admin-or-owner", "app/views/quotes/show.html.erb" => "admin-or-owner", "app/views/story_ideas/show.html.erb" => "admin-or-owner", "app/views/workshop_variation_ideas/show.html.erb" => "admin-or-owner", @@ -162,9 +161,10 @@ "app/views/workshop_variations/edit.html.erb" => "admin-only bg-blue-100", "app/views/workshops/edit.html.erb" => "admin-only bg-blue-100", - # ─── public registration ─── + # ─── public registration / slug-based views ─── "app/views/events/public_registrations/new.html.erb" => "public", - "app/views/events/public_registrations/show.html.erb" => "admin-or-owner" + "app/views/events/public_registrations/show.html.erb" => "public", + "app/views/events/registrations/show.html.erb" => "public" }.freeze EXPECTED_MAPPINGS.each do |view_path, expected_bg_class|