+ `).join('')
+
+ resultsDropdown.innerHTML = resultsHtml
+ resultsDropdown.style.display = 'block'
+
+ // Add click handlers to results
+ resultsDropdown.querySelectorAll('.person-result').forEach(result => {
+ result.addEventListener('click', () => {
+ this.selectPerson(result, select)
+ resultsDropdown.style.display = 'none'
+ })
+ })
+ }
+
+ selectPerson(resultElement, select) {
+ const personId = resultElement.dataset.personId
+ const personName = resultElement.dataset.personName
+
+ // Update the hidden select
+ select.innerHTML = ``
+ select.value = personId
+
+ // Update the search input
+ const searchInput = this.inputTarget
+ searchInput.value = personName
+
+ // Trigger change event for form handling
+ select.dispatchEvent(new Event('change', { bubbles: true }))
+ }
+}
diff --git a/app/assets/stylesheets/better_together/application.scss b/app/assets/stylesheets/better_together/application.scss
index 2b5614eae..641bafa30 100644
--- a/app/assets/stylesheets/better_together/application.scss
+++ b/app/assets/stylesheets/better_together/application.scss
@@ -31,6 +31,7 @@
@use 'conversations';
@use 'forms';
@use 'image-galleries';
+@use 'invitations';
@use 'maps';
@use 'metrics';
@use 'navigation';
diff --git a/app/assets/stylesheets/better_together/invitations.scss b/app/assets/stylesheets/better_together/invitations.scss
new file mode 100644
index 000000000..f0e341b8f
--- /dev/null
+++ b/app/assets/stylesheets/better_together/invitations.scss
@@ -0,0 +1,5 @@
+
+.profile-image.invitee {
+ width: 32px;
+ height: 32px;
+}
\ No newline at end of file
diff --git a/app/controllers/better_together/application_controller.rb b/app/controllers/better_together/application_controller.rb
index c3e63861e..1f4ddac3c 100644
--- a/app/controllers/better_together/application_controller.rb
+++ b/app/controllers/better_together/application_controller.rb
@@ -158,7 +158,13 @@ def user_not_authorized(exception) # rubocop:todo Metrics/AbcSize, Metrics/Metho
]
else
flash[:error] = message # Use flash for regular redirects
- redirect_back(fallback_location: home_page_path)
+
+ # For unauthenticated users, redirect to login
+ if current_user.nil?
+ redirect_to new_user_session_path(locale: I18n.locale)
+ else
+ redirect_back(fallback_location: home_page_path)
+ end
end
end
# rubocop:enable Metrics/MethodLength
diff --git a/app/controllers/better_together/events/invitations_controller.rb b/app/controllers/better_together/events/invitations_controller.rb
index 77018e6f9..7fd61e5d8 100644
--- a/app/controllers/better_together/events/invitations_controller.rb
+++ b/app/controllers/better_together/events/invitations_controller.rb
@@ -2,18 +2,14 @@
module BetterTogether
module Events
- class InvitationsController < ApplicationController # rubocop:todo Style/Documentation
+ class InvitationsController < ApplicationController # rubocop:todo Style/Documentation, Metrics/ClassLength
before_action :set_event
before_action :set_invitation, only: %i[destroy resend]
- after_action :verify_authorized
-
- def create # rubocop:todo Metrics/MethodLength
- @invitation = BetterTogether::EventInvitation.new(invitation_params)
- @invitation.invitable = @event
- @invitation.inviter = helpers.current_person
- @invitation.status = 'pending'
- @invitation.valid_from ||= Time.zone.now
+ after_action :verify_authorized, except: %i[available_people]
+ after_action :verify_policy_scoped, only: %i[available_people]
+ def create
+ @invitation = build_invitation
authorize @invitation
if @invitation.save
@@ -26,8 +22,14 @@ def create # rubocop:todo Metrics/MethodLength
def destroy
authorize @invitation
+ invitation_dom_id = helpers.dom_id(@invitation)
@invitation.destroy
- respond_success(@invitation, :ok)
+
+ respond_to do |format|
+ format.html { redirect_to @event, notice: t('flash.generic.destroyed', resource: t('resources.invitation')) }
+ format.turbo_stream { render_destroy_turbo_stream(invitation_dom_id) }
+ format.json { render json: { id: @invitation.id }, status: :ok }
+ end
end
def resend
@@ -36,6 +38,18 @@ def resend
respond_success(@invitation, :ok)
end
+ def available_people
+ invited_ids = invited_person_ids
+ people = build_available_people_query(invited_ids)
+ people = apply_search_filter(people) if params[:search].present?
+
+ formatted_people = people.limit(20).map do |person|
+ { value: person.id, text: person.name }
+ end
+
+ render json: formatted_people
+ end
+
private
def set_event
@@ -49,36 +63,116 @@ def set_invitation
end
def invitation_params
- params.require(:invitation).permit(:invitee_email, :valid_from, :valid_until, :locale, :role_id)
+ params.require(:invitation).permit(:invitee_id, :invitee_email, :valid_from, :valid_until, :locale, :role_id)
end
- def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
- # Simple throttling: skip if sent in last 15 minutes
- if invitation.last_sent.present? && invitation.last_sent > 15.minutes.ago
+ def build_invitation
+ invitation = BetterTogether::EventInvitation.new(invitation_params)
+ invitation.invitable = @event
+ invitation.inviter = helpers.current_person
+ invitation.status = 'pending'
+ invitation.valid_from ||= Time.zone.now
+
+ # Handle person invitation by ID
+ setup_person_invitation(invitation) if params.dig(:invitation, :invitee_id).present?
+
+ invitation
+ end
+
+ def setup_person_invitation(invitation)
+ invitation.invitee = BetterTogether::Person.find(params[:invitation][:invitee_id])
+ # Use the person's email address and locale
+ invitation.invitee_email = invitation.invitee.email
+ invitation.locale = invitation.invitee.locale || I18n.default_locale
+ end
+
+ def render_destroy_turbo_stream(invitation_dom_id)
+ flash.now[:notice] = t('flash.generic.destroyed', resource: t('resources.invitation'))
+ render turbo_stream: [
+ turbo_stream.remove(invitation_dom_id),
+ turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages',
+ locals: { flash: })
+ ]
+ end
+
+ def invited_person_ids
+ # Get IDs of people who are already invited to this event
+ # We use EventInvitation directly to avoid the default scope with includes(:invitee)
+ BetterTogether::EventInvitation
+ .where(invitable: @event, invitee_type: 'BetterTogether::Person')
+ .pluck(:invitee_id)
+ end
+
+ def build_available_people_query(invited_ids)
+ # Search for people excluding those already invited and those without email
+ # People have email through either user.email or contact_detail.email_addresses (association)
+ policy_scope(BetterTogether::Person)
+ .left_joins(:user, contact_detail: :email_addresses)
+ .where.not(id: invited_ids)
+ .where(
+ 'better_together_users.email IS NOT NULL OR ' \
+ 'better_together_email_addresses.email IS NOT NULL'
+ )
+ end
+
+ def apply_search_filter(people)
+ search_term = "%#{params[:search]}%"
+ people.joins(:string_translations)
+ .where('mobility_string_translations.value ILIKE ? AND mobility_string_translations.key = ?',
+ search_term, 'name')
+ .distinct
+ end
+
+ def recently_sent?(invitation)
+ return false unless invitation.last_sent.present?
+
+ if invitation.last_sent > 15.minutes.ago
Rails.logger.info("Invitation #{invitation.id} recently sent; skipping resend")
- return
+ true
+ else
+ false
end
+ end
+
+ def send_notification_to_user(invitation)
+ # Send notification to existing user through the notification system
+ BetterTogether::EventInvitationNotifier.with(record: invitation.invitable,
+ invitation:).deliver_later(invitation.invitee)
+ invitation.update_column(:last_sent, Time.zone.now)
+ end
- if invitation.invitee.present?
- BetterTogether::EventInvitationNotifier.with(invitation:).deliver_later(invitation.invitee)
- invitation.update_column(:last_sent, Time.zone.now)
- elsif invitation.respond_to?(:invitee_email) && invitation[:invitee_email].present?
- BetterTogether::EventInvitationsMailer.invite(invitation).deliver_later
- invitation.update_column(:last_sent, Time.zone.now)
+ def send_email_invitation(invitation)
+ # Send email directly to external email address (bypassing notification system)
+ BetterTogether::EventInvitationsMailer.with(invitation:).invite.deliver_later
+ invitation.update_column(:last_sent, Time.zone.now)
+ end
+
+ def render_success_turbo_stream(status)
+ flash.now[:notice] = t('flash.generic.queued', resource: t('resources.invitation'))
+ render turbo_stream: [
+ turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages',
+ locals: { flash: }),
+ turbo_stream.replace('event_invitations_table_body',
+ partial: 'better_together/events/invitation_row',
+ collection: @event.invitations.order(:status, :created_at))
+ ], status:
+ end
+
+ def notify_invitee(invitation)
+ # Simple throttling: skip if sent in last 15 minutes
+ return if recently_sent?(invitation)
+
+ if invitation.for_existing_user? && invitation.invitee.present?
+ send_notification_to_user(invitation)
+ elsif invitation.for_email?
+ send_email_invitation(invitation)
end
end
- def respond_success(invitation, status) # rubocop:todo Metrics/MethodLength
+ def respond_success(invitation, status)
respond_to do |format|
format.html { redirect_to @event, notice: t('flash.generic.queued', resource: t('resources.invitation')) }
- format.turbo_stream do
- render turbo_stream: [
- turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages',
- locals: { flash: }),
- turbo_stream.replace('event_invitations_table_body',
- partial: 'better_together/events/pending_invitation_rows', locals: { event: @event })
- ], status:
- end
+ format.turbo_stream { render_success_turbo_stream(status) }
format.json { render json: { id: invitation.id }, status: }
end
end
diff --git a/app/controllers/better_together/events_controller.rb b/app/controllers/better_together/events_controller.rb
index 440c19e6d..ac8db71a1 100644
--- a/app/controllers/better_together/events_controller.rb
+++ b/app/controllers/better_together/events_controller.rb
@@ -3,6 +3,13 @@
module BetterTogether
# CRUD for BetterTogether::Event
class EventsController < FriendlyResourceController # rubocop:todo Metrics/ClassLength
+ include InvitationTokenAuthorization
+ include NotificationReadable
+
+ # Prepend resource instance setting for privacy check
+ prepend_before_action :set_resource_instance, only: %i[show ics]
+ prepend_before_action :set_event_for_privacy_check, only: [:show]
+
before_action if: -> { Rails.env.development? } do
# Make sure that all subclasses are loaded in dev to generate type selector
Rails.application.eager_load!
@@ -25,6 +32,11 @@ def show
return
end
+ # Check for valid invitation if accessing via invitation token
+ @current_invitation = find_invitation_by_token
+
+ mark_match_notifications_read_for(resource_instance)
+
super
end
@@ -91,8 +103,159 @@ def resource_class
::BetterTogether::Event
end
+ def resource_collection
+ # Set invitation token for policy scope
+ invitation_token = params[:invitation_token] || session[:event_invitation_token]
+ self.current_invitation_token = invitation_token
+
+ super
+ end
+
+ # Override the parent's authorize_resource method to include invitation token context
+ def authorize_resource
+ # Set invitation token for authorization
+ invitation_token = params[:invitation_token] || session[:event_invitation_token]
+ self.current_invitation_token = invitation_token
+
+ authorize resource_instance
+ end
+
+ # Helper method to find invitation by token
+ def find_invitation_by_token
+ token = extract_invitation_token
+ return nil unless token.present?
+
+ invitation = find_valid_invitation(token)
+ persist_invitation_to_session(invitation, token) if invitation
+ invitation
+ end
+
private
+ def extract_invitation_token
+ params[:invitation_token].presence || params[:token].presence || current_invitation_token
+ end
+
+ def find_valid_invitation(token)
+ if @event
+ BetterTogether::EventInvitation.pending.not_expired.find_by(token: token, invitable: @event)
+ else
+ BetterTogether::EventInvitation.pending.not_expired.find_by(token: token)
+ end
+ end
+
+ def persist_invitation_to_session(invitation, _token)
+ return unless token_came_from_params?
+
+ store_invitation_in_session(invitation)
+ locale_from_invitation(invitation)
+ self.current_invitation_token = invitation.token
+ end
+
+ def token_came_from_params?
+ params[:invitation_token].present? || params[:token].present?
+ end
+
+ def store_invitation_in_session(invitation)
+ session[:event_invitation_token] = invitation.token
+ session[:event_invitation_expires_at] = platform_invitation_expiry_time.from_now
+ end
+
+ def locale_from_invitation(invitation)
+ return unless invitation.locale.present?
+
+ I18n.locale = invitation.locale
+ session[:locale] = I18n.locale
+ end
+
+ # Process event invitation tokens before inherited (ApplicationController) callbacks
+ # so we can bypass platform privacy checks for valid event invitations and
+ # return 404 for invalid tokens when the platform is private.
+ # prepend_before_action :process_event_invitation_for_privacy, only: %i[show]
+
+ # Override privacy check to handle event-specific invitation tokens.
+ # This keeps event lookup logic inside the events controller and avoids
+ # embedding event knowledge in ApplicationController.
+ def check_platform_privacy
+ return super if platform_public_or_user_authenticated?
+
+ token = extract_invitation_token_for_privacy
+ return super unless token_and_params_present?(token)
+
+ invitation_any = find_any_invitation_by_token(token)
+ return render_not_found unless invitation_any.present?
+
+ return redirect_to_sign_in if invitation_invalid_or_expired?(invitation_any)
+
+ result = handle_valid_invitation_token(token)
+ return result if result # Return true if invitation processed successfully
+
+ # Fall back to ApplicationController implementation for other cases
+ super
+ end
+
+ def platform_public_or_user_authenticated?
+ helpers.host_platform.privacy_public? || current_user.present?
+ end
+
+ def extract_invitation_token_for_privacy
+ params[:invitation_token].presence || params[:token].presence || session[:event_invitation_token].presence
+ end
+
+ def token_and_params_present?(token)
+ token.present? && params[:id].present?
+ end
+
+ def find_any_invitation_by_token(token)
+ ::BetterTogether::EventInvitation.find_by(token: token)
+ end
+
+ def invitation_invalid_or_expired?(invitation_any)
+ expired = invitation_any.valid_until.present? && Time.current > invitation_any.valid_until
+ !invitation_any.pending? || expired
+ end
+
+ def redirect_to_sign_in
+ redirect_to new_user_session_path(locale: I18n.locale)
+ end
+
+ def handle_valid_invitation_token(token)
+ invitation = ::BetterTogether::EventInvitation.pending.not_expired.find_by(token: token)
+ return render_not_found_for_mismatched_invitation unless invitation&.invitable.present?
+
+ event = load_event_safely
+ return false unless event # Return false to fall back to super in check_platform_privacy
+ return render_not_found unless invitation_matches_event?(invitation, event)
+
+ store_invitation_and_grant_access(invitation)
+ end
+
+ def render_not_found_for_mismatched_invitation
+ render_not_found
+ end
+
+ def load_event_safely
+ @event || resource_class.friendly.find(params[:id])
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ def invitation_matches_event?(invitation, event)
+ invitation.invitable.id == event.id
+ end
+
+ def store_invitation_and_grant_access(invitation)
+ session[:event_invitation_token] = invitation.token
+ session[:event_invitation_expires_at] = 24.hours.from_now
+ I18n.locale = invitation.locale if invitation.locale.present?
+ session[:locale] = I18n.locale
+ self.current_invitation_token = invitation.token
+ end
+
+ def set_event_for_privacy_check
+ @event = @resource if @resource.is_a?(BetterTogether::Event)
+ end
+
# rubocop:todo Metrics/MethodLength
def rsvp_update(status) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
set_resource_instance
diff --git a/app/controllers/better_together/invitations_controller.rb b/app/controllers/better_together/invitations_controller.rb
index 74524e3b3..d2ce26304 100644
--- a/app/controllers/better_together/invitations_controller.rb
+++ b/app/controllers/better_together/invitations_controller.rb
@@ -3,67 +3,110 @@
module BetterTogether
class InvitationsController < ApplicationController # rubocop:todo Style/Documentation
# skip_before_action :authenticate_user!
- before_action :find_invitation_by_token
+ prepend_before_action :find_invitation_by_token
+ skip_before_action :check_platform_privacy, if: -> { @invitation.present? }
def show
@event = @invitation.invitable if @invitation.is_a?(BetterTogether::EventInvitation)
render :show
end
- def accept # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
+ def accept
ensure_authenticated!
return if performed?
person = helpers.current_person
- if @invitation.invitee.present? && @invitation.invitee != person
- redirect_to new_user_session_path(locale: I18n.locale), alert: t('flash.generic.unauthorized') and return
- end
-
- @invitation.update!(invitee: person) if @invitation.invitee.blank?
- if @invitation.respond_to?(:accept!)
- # EventInvitation implements accept!(invitee_person:)
- @invitation.accept!(invitee_person: person)
- else
- @invitation.update!(status: 'accepted')
- end
+ return unless invitee_authorized?(person)
+ process_invitation_acceptance(person)
redirect_to polymorphic_path(@invitation.invitable),
notice: t('flash.generic.updated', resource: t('resources.invitation'))
end
- def decline # rubocop:todo Metrics/MethodLength
- ensure_authenticated!
+ def decline
+ # ensure_authenticated!
return if performed?
- if @invitation.respond_to?(:decline!)
- @invitation.decline!
- else
- @invitation.update!(status: 'declined')
- end
-
- # For event invitations, redirect to the event. Otherwise use root path.
- redirect_path = if @invitation.respond_to?(:event) && @invitation.event
- polymorphic_path(@invitation.invitable)
- else
- root_path(locale: I18n.locale)
- end
-
- redirect_to redirect_path,
+ process_invitation_decline
+ redirect_to determine_decline_redirect_path,
notice: t('flash.generic.updated', resource: t('resources.invitation'))
end
private
def find_invitation_by_token
- token = params[:token].to_s
+ token = params[:invitation_token].presence || params[:token].presence
@invitation = BetterTogether::Invitation.pending.not_expired.find_by(token: token)
render_not_found unless @invitation
end
def ensure_authenticated!
- return if helpers.current_person.present?
+ return if current_user
+
+ store_invitation_in_session
+ redirect_to determine_auth_redirect_path, notice: determine_auth_notice
+ end
+
+ def store_invitation_in_session
+ # Store invitation token in session for after authentication
+ return unless @invitation.is_a?(BetterTogether::EventInvitation)
+
+ session[:event_invitation_token] = @invitation.token
+ session[:event_invitation_expires_at] = 24.hours.from_now
+ end
+
+ def determine_auth_redirect_path
+ if BetterTogether::User.find_by(email: @invitation.invitee_email).present?
+ new_user_session_path(locale: I18n.locale)
+ else
+ new_user_registration_path(locale: I18n.locale)
+ end
+ end
- redirect_to new_user_session_path(locale: I18n.locale), alert: t('flash.generic.unauthorized')
+ def determine_auth_notice
+ if BetterTogether::User.find_by(email: @invitation.invitee_email).present?
+ t('better_together.invitations.login_to_respond',
+ default: 'Please log in to respond to your invitation.')
+ else
+ t('better_together.invitations.register_to_respond',
+ default: 'Please register to respond to your invitation.')
+ end
+ end
+
+ def invitee_authorized?(person)
+ if @invitation.invitee.present? && @invitation.invitee != person
+ redirect_to new_user_session_path(locale: I18n.locale), alert: t('flash.generic.unauthorized')
+ false
+ else
+ true
+ end
+ end
+
+ def process_invitation_acceptance(person)
+ @invitation.update!(invitee: person) if @invitation.invitee.blank?
+ if @invitation.respond_to?(:accept!)
+ # EventInvitation implements accept!(invitee_person:)
+ @invitation.accept!(invitee_person: person)
+ else
+ @invitation.update!(status: 'accepted')
+ end
+ end
+
+ def process_invitation_decline
+ if @invitation.respond_to?(:decline!)
+ @invitation.decline!
+ else
+ @invitation.update!(status: 'declined')
+ end
+ end
+
+ def determine_decline_redirect_path
+ # For event invitations, redirect to the event. Otherwise use root path.
+ if @invitation.respond_to?(:event) && @invitation.event
+ polymorphic_path(@invitation.invitable)
+ else
+ root_path(locale: I18n.locale)
+ end
end
end
end
diff --git a/app/controllers/better_together/person_blocks_controller.rb b/app/controllers/better_together/person_blocks_controller.rb
index feb8bfabc..621d7e290 100644
--- a/app/controllers/better_together/person_blocks_controller.rb
+++ b/app/controllers/better_together/person_blocks_controller.rb
@@ -12,34 +12,54 @@ def index # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
# AC-2.11: I can search through my blocked users by name and slug
@blocked_people = helpers.current_person.blocked_people
if params[:search].present?
- # Search by translated name and slug using includes and references
- # Apply policy scope to ensure only authorized people are searchable
+ # Search by name and identifier - use simpler approach for debugging
search_term = params[:search].strip
- authorized_person_ids = policy_scope(BetterTogether::Person).pluck(:id)
-
- @blocked_people = @blocked_people.where(id: authorized_person_ids)
- .includes(:string_translations)
- .references(:string_translations)
- .where(string_translations: { key: %w[name slug] })
- .where('string_translations.value ILIKE ?', "%#{search_term}%")
- .distinct
+
+ # First, let's just search without any restrictions to see if we can find any matches
+ all_matching_people = BetterTogether::Person.joins(:string_translations)
+ .where(
+ 'mobility_string_translations.key = ? AND ' \
+ 'mobility_string_translations.value ILIKE ?',
+ 'name', "%#{search_term}%"
+ )
+
+ # Also search by identifier
+ identifier_matches = BetterTogether::Person.where(
+ 'better_together_people.identifier ILIKE ?',
+ "%#{search_term}%"
+ )
+
+ # Get all matching person IDs
+ all_matching_ids = (all_matching_people.pluck(:id) + identifier_matches.pluck(:id)).uniq
+
+ # Filter blocked people to only include those matching the search
+ @blocked_people = @blocked_people.where(id: all_matching_ids)
end
# AC-2.12: I can see when I blocked each user (provide person_blocks for timestamp info)
@person_blocks = helpers.current_person.person_blocks.includes(:blocked)
if params[:search].present?
- # Filter person_blocks by matching blocked person names and slugs
- # Apply policy scope to ensure only authorized people are searchable
+ # Filter person_blocks by matching blocked person names - use same simplified approach
search_term = params[:search].strip
- authorized_person_ids = policy_scope(BetterTogether::Person).pluck(:id)
+ # Search for people by name in translations
+ name_search = BetterTogether::Person.joins(:string_translations)
+ .where(
+ 'mobility_string_translations.key = ? AND ' \
+ 'mobility_string_translations.value ILIKE ?',
+ 'name', "%#{search_term}%"
+ )
+
+ # Also search by identifier
+ identifier_search = BetterTogether::Person.where(
+ 'better_together_people.identifier ILIKE ?',
+ "%#{search_term}%"
+ )
+
+ # Get matching person IDs and filter person_blocks
+ matching_person_ids = (name_search.pluck(:id) + identifier_search.pluck(:id)).uniq
@person_blocks = @person_blocks.joins(:blocked)
- .where(better_together_people: { id: authorized_person_ids })
- .includes(blocked: :string_translations)
- .references(:string_translations)
- .where(string_translations: { key: %w[name slug] })
- .where('string_translations.value ILIKE ?', "%#{search_term}%")
- .distinct
+ .where(better_together_people: { id: matching_person_ids })
end
# AC-2.15: I can see how many users I have blocked
diff --git a/app/controllers/better_together/users/registrations_controller.rb b/app/controllers/better_together/users/registrations_controller.rb
index e93f4d637..e5dedc419 100644
--- a/app/controllers/better_together/users/registrations_controller.rb
+++ b/app/controllers/better_together/users/registrations_controller.rb
@@ -8,6 +8,7 @@ class RegistrationsController < ::Devise::RegistrationsController # rubocop:todo
skip_before_action :check_platform_privacy
before_action :set_required_agreements, only: %i[new create]
+ before_action :set_event_invitation_from_session, only: %i[new create]
before_action :configure_account_update_params, only: [:update]
# PUT /resource
@@ -62,51 +63,26 @@ def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
def new
super do |user|
+ # Pre-fill email from platform invitation
user.email = @platform_invitation.invitee_email if @platform_invitation && user.email.empty?
+
+ if @event_invitation
+ # Pre-fill email from event invitation
+ user.email = @event_invitation.invitee_email if @event_invitation && user.email.empty?
+ user.person = @event_invitation.invitee if @event_invitation.invitee.present?
+ end
end
end
- def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
+ def create
unless agreements_accepted?
- build_resource(sign_up_params)
- resource.errors.add(:base, I18n.t('devise.registrations.new.agreements_required'))
- respond_with resource
+ handle_agreements_not_accepted
return
end
- ActiveRecord::Base.transaction do # rubocop:todo Metrics/BlockLength
+ ActiveRecord::Base.transaction do
super do |user|
- return unless user.persisted?
-
- user.build_person(person_params)
-
- if user.save!
- user.reload
-
- community_role = if @platform_invitation
- @platform_invitation.community_role
- else
- ::BetterTogether::Role.find_by(identifier: 'community_member')
- end
-
- helpers.host_community.person_community_memberships.create!(
- member: user.person,
- role: community_role
- )
-
- if @platform_invitation
- if @platform_invitation.platform_role
- helpers.host_platform.person_platform_memberships.create!(
- member: user.person,
- role: @platform_invitation.platform_role
- )
- end
-
- @platform_invitation.accept!(invitee: user.person)
- end
-
- create_agreement_participants(user.person)
- end
+ handle_user_creation(user) if user.persisted?
end
end
end
@@ -129,6 +105,9 @@ def set_required_agreements
end
def after_sign_up_path_for(resource)
+ # Redirect to event if signed up via event invitation
+ return better_together.event_path(@event_invitation.event) if @event_invitation&.event
+
if is_navigational_format? && helpers.host_platform&.privacy_private?
return better_together.new_user_session_path
end
@@ -136,6 +115,87 @@ def after_sign_up_path_for(resource)
super
end
+ def set_event_invitation_from_session
+ return unless session[:event_invitation_token].present?
+
+ # Check if session token is still valid
+ return if session[:event_invitation_expires_at].present? &&
+ Time.current > session[:event_invitation_expires_at]
+
+ @event_invitation = ::BetterTogether::EventInvitation.pending.not_expired
+ .find_by(token: session[:event_invitation_token])
+
+ nil if @event_invitation
+ end
+
+ def determine_community_role
+ return @platform_invitation.community_role if @platform_invitation
+
+ # For event invitations, use the event creator's community
+ return @event_invitation.role if @event_invitation && @event_invitation.role.present?
+
+ # Default role
+ ::BetterTogether::Role.find_by(identifier: 'community_member')
+ end
+
+ def handle_agreements_not_accepted
+ build_resource(sign_up_params)
+ resource.errors.add(:base, I18n.t('devise.registrations.new.agreements_required'))
+ respond_with resource
+ end
+
+ def handle_user_creation(user)
+ setup_person_for_user(user)
+ return unless user.save!
+
+ user.reload
+ setup_community_membership(user)
+ handle_platform_invitation(user)
+ handle_event_invitation(user)
+ create_agreement_participants(user.person)
+ end
+
+ def setup_person_for_user(user)
+ if @event_invitation && @event_invitation.invitee.present?
+ user.person = @event_invitation.invitee
+ user.person.update(person_params)
+ else
+ user.build_person(person_params)
+ end
+ end
+
+ def setup_community_membership(user)
+ community_role = determine_community_role
+ helpers.host_community.person_community_memberships.find_or_create_by!(
+ member: user.person,
+ role: community_role
+ )
+ end
+
+ def handle_platform_invitation(user)
+ return unless @platform_invitation
+
+ if @platform_invitation.platform_role
+ helpers.host_platform.person_platform_memberships.create!(
+ member: user.person,
+ role: @platform_invitation.platform_role
+ )
+ end
+
+ @platform_invitation.accept!(invitee: user.person)
+ end
+
+ def handle_event_invitation(user)
+ return unless @event_invitation
+
+ @event_invitation.update!(invitee: user.person)
+ @event_invitation.accept!(invitee_person: user.person)
+
+ # Clear session data
+ session.delete(:event_invitation_token)
+ session.delete(:event_invitation_expires_at)
+ end
+
def after_inactive_sign_up_path_for(resource)
if is_navigational_format? && helpers.host_platform&.privacy_private?
return better_together.new_user_session_path
diff --git a/app/controllers/concerns/better_together/invitation_token_authorization.rb b/app/controllers/concerns/better_together/invitation_token_authorization.rb
new file mode 100644
index 000000000..54c5d2b52
--- /dev/null
+++ b/app/controllers/concerns/better_together/invitation_token_authorization.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module BetterTogether
+ # Concern to override Pundit's authorize method to support invitation token authorization
+ # This allows policies to receive invitation tokens for context-aware authorization
+ module InvitationTokenAuthorization
+ extend ActiveSupport::Concern
+
+ included do
+ attr_reader :current_invitation_token
+ end
+
+ private
+
+ # Override Pundit's authorize method to pass invitation token to policies
+ # @param record [Object] The record to authorize
+ # @param query [Symbol] The policy method to call (defaults to action query) - can be positional or keyword arg
+ # @param policy_class [Class] Optional policy class override
+ # @return [Object] The authorized record
+ def authorize(record, query = nil, policy_class: nil)
+ # Handle both old syntax: authorize(record, :query?) and new syntax: authorize(record, query: :query?)
+ query ||= "#{action_name}?"
+ policy_class ||= policy_class_for(record)
+
+ # Create policy instance with invitation token
+ policy = policy_class.new(current_user, record, invitation_token: current_invitation_token)
+
+ # Check authorization
+ raise Pundit::NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
+
+ # Mark that authorization was performed (required for verify_authorized)
+ @_pundit_policy_authorized = true
+
+ record
+ end
+
+ # Override Pundit's policy_scope method to pass invitation token to policy scopes
+ # @param scope [Class] The scope class (typically a model class)
+ # @param policy_scope_class [Class] Optional policy scope class override
+ # @return [Object] The scoped collection
+ def policy_scope(scope, policy_scope_class: nil, invitation_token: nil)
+ policy_scope_class ||= policy_scope_class_for(scope)
+
+ # Use provided invitation token or fall back to current
+ token = invitation_token || current_invitation_token
+
+ # Create policy scope instance with invitation token
+ scope = policy_scope_class.new(current_user, scope, invitation_token: token).resolve
+
+ @_pundit_policy_scoped = true
+
+ scope
+ end
+
+ # Set the current invitation token for use in authorization
+ # @param token [String] The invitation token
+ def current_invitation_token=(token)
+ @current_invitation_token = token
+ end
+
+ # Helper method to determine policy class for a record
+ # @param record [Object] The record to find policy for
+ # @return [Class] The policy class
+ def policy_class_for(record)
+ if record.is_a?(Class)
+ "#{record.name}Policy".constantize
+ else
+ "#{record.class.name}Policy".constantize
+ end
+ end
+
+ # Helper method to determine policy scope class for a scope
+ # @param scope [Class] The scope class
+ # @return [Class] The policy scope class
+ def policy_scope_class_for(scope)
+ "#{scope.name}Policy::Scope".constantize
+ end
+ end
+end
diff --git a/app/helpers/better_together/image_helper.rb b/app/helpers/better_together/image_helper.rb
index 0c8a73cb9..92d8e2067 100644
--- a/app/helpers/better_together/image_helper.rb
+++ b/app/helpers/better_together/image_helper.rb
@@ -12,8 +12,8 @@ def cover_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength, M
image_width = options[:width] || 2400
image_height = options[:height] || 600
image_format = options[:format] || 'jpg'
- image_alt = options[:alt] || entity
- image_title = options[:title] || entity
+ image_alt = options[:alt] || entity&.to_s || entity&.name || 'Cover image'
+ image_title = options[:title] || entity&.to_s || entity&.name || 'Cover image'
image_tag_attributes = {
class: image_classes,
style: image_style,
@@ -52,8 +52,8 @@ def card_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength, Me
image_width = options[:width] || 1200
image_height = options[:height] || 800
image_format = options[:format] || 'jpg'
- image_alt = options[:alt] || entity
- image_title = options[:title] || entity
+ image_alt = options[:alt] || entity&.to_s || entity&.name || 'Card image'
+ image_title = options[:title] || entity&.to_s || entity&.name || 'Card image'
image_tag_attributes = {
class: image_classes,
style: image_style,
diff --git a/app/javascript/controllers/better_together/slim_select_controller.js b/app/javascript/controllers/better_together/slim_select_controller.js
index a8bd7706d..0f71df33b 100644
--- a/app/javascript/controllers/better_together/slim_select_controller.js
+++ b/app/javascript/controllers/better_together/slim_select_controller.js
@@ -31,7 +31,7 @@ export default class extends Controller {
return new Promise((resolve, reject) => {
const url = new URL(options.ajax.url, window.location.origin);
- url.searchParams.append('q', search);
+ url.searchParams.append('search', search);
fetch(url.toString(), {
method: 'GET',
diff --git a/app/jobs/better_together/elasticsearch_index_job.rb b/app/jobs/better_together/elasticsearch_index_job.rb
index f5cf1fa40..7eca5bc5b 100644
--- a/app/jobs/better_together/elasticsearch_index_job.rb
+++ b/app/jobs/better_together/elasticsearch_index_job.rb
@@ -5,11 +5,23 @@ module BetterTogether
# This job is responsible for indexing and deleting documents in Elasticsearch
# when records are created, updated, or destroyed.
class ElasticsearchIndexJob < ApplicationJob
- queue_as :default
+ queue_as :es_indexing
+
+ # Don't retry on deserialization errors - the record no longer exists
+ discard_on ActiveJob::DeserializationError
def perform(record, action)
return unless record.respond_to? :__elasticsearch__
+ execute_elasticsearch_action(record, action)
+ rescue ActiveRecord::RecordNotFound
+ # Record was deleted before the job could run - this is expected for delete operations
+ Rails.logger.info "ElasticsearchIndexJob: Record no longer exists, skipping #{action} operation"
+ end
+
+ private
+
+ def execute_elasticsearch_action(record, action)
case action
when :index
record.__elasticsearch__.index_document
diff --git a/app/jobs/better_together/geography/geocoding_job.rb b/app/jobs/better_together/geography/geocoding_job.rb
index d4674860f..7c6c0ec82 100644
--- a/app/jobs/better_together/geography/geocoding_job.rb
+++ b/app/jobs/better_together/geography/geocoding_job.rb
@@ -6,9 +6,15 @@ class GeocodingJob < ApplicationJob # rubocop:todo Style/Documentation
queue_as :geocoding
retry_on StandardError, wait: :polynomially_longer, attempts: 5
+ # Don't retry on deserialization errors - the record no longer exists
+ discard_on ActiveJob::DeserializationError
+
def perform(geocodable)
coords = geocodable.geocode
geocodable.save if coords
+ rescue ActiveRecord::RecordNotFound
+ # Record was deleted before the job could run
+ Rails.logger.info 'GeocodingJob: Record no longer exists, skipping geocoding operation'
end
end
end
diff --git a/app/mailers/better_together/event_invitations_mailer.rb b/app/mailers/better_together/event_invitations_mailer.rb
index 9316c8b2a..fb4fe8720 100644
--- a/app/mailers/better_together/event_invitations_mailer.rb
+++ b/app/mailers/better_together/event_invitations_mailer.rb
@@ -2,17 +2,34 @@
module BetterTogether
class EventInvitationsMailer < ApplicationMailer # rubocop:todo Style/Documentation
- def invite(invitation)
- @invitation = invitation
- @event = invitation.invitable
- @invitation_url = invitation.url_for_review
+ # Parameterized mailer: Noticed calls mailer.with(params).invite
+ # so read the invitation from params rather than using a positional arg.
+ def invite
+ invitation = params[:invitation]
+ setup_invitation_data(invitation)
- to_email = invitation[:invitee_email].to_s
+ to_email = invitation&.invitee_email.to_s
return if to_email.blank?
- mail(to: to_email,
- subject: I18n.t('better_together.event_invitations_mailer.invite.subject',
- default: 'You are invited to an event'))
+ send_invitation_email(invitation, to_email)
+ end
+
+ private
+
+ def setup_invitation_data(invitation)
+ @invitation = invitation
+ @event = invitation&.invitable
+ @invitation_url = invitation&.url_for_review
+ end
+
+ def send_invitation_email(invitation, to_email)
+ # Use the invitation's locale for proper internationalization
+ I18n.with_locale(invitation&.locale) do
+ mail(to: to_email,
+ subject: I18n.t('better_together.event_invitations_mailer.invite.subject',
+ event_name: @event&.name,
+ default: 'You are invited to %s'))
+ end
end
end
end
diff --git a/app/models/better_together/event.rb b/app/models/better_together/event.rb
index b4c080793..e16fda288 100644
--- a/app/models/better_together/event.rb
+++ b/app/models/better_together/event.rb
@@ -17,7 +17,11 @@ class Event < ApplicationRecord
attachable_cover_image
- has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
+ has_many :event_attendances, class_name: 'BetterTogether::EventAttendance',
+ foreign_key: :event_id, inverse_of: :event, dependent: :destroy
+ has_many :invitations, -> { includes(:invitee, :inviter) },
+ class_name: 'BetterTogether::EventInvitation',
+ foreign_key: :invitable_id, inverse_of: :invitable, dependent: :destroy
has_many :attendees, through: :event_attendances, source: :person
has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy
diff --git a/app/models/better_together/event_invitation.rb b/app/models/better_together/event_invitation.rb
index 6923c871c..ed45db865 100644
--- a/app/models/better_together/event_invitation.rb
+++ b/app/models/better_together/event_invitation.rb
@@ -13,10 +13,15 @@ class EventInvitation < Invitation
validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) }
validate :invitee_presence
+ validate :invitee_uniqueness_for_event
# Ensure token is generated before validation
before_validation :ensure_token_present
+ # Scopes for different invitation types
+ scope :for_existing_users, -> { where.not(invitee: nil) }
+ scope :for_email_addresses, -> { where(invitee: nil).where.not(invitee_email: [nil, '']) }
+
# Convenience helpers (invitable is the event)
def event
invitable
@@ -26,6 +31,9 @@ def after_accept!(invitee_person: nil)
person = invitee_person || resolve_invitee_person
return unless person && event
+ # Ensure the person has community membership for the event's community
+ ensure_community_membership!(person)
+
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event:, person:)
attendance.status = 'going'
attendance.save!
@@ -43,7 +51,29 @@ def decline!
end
def url_for_review
- BetterTogether::Engine.routes.url_helpers.invitation_url(token, locale: I18n.locale)
+ BetterTogether::Engine.routes.url_helpers.event_url(
+ invitable.slug,
+ locale: locale,
+ invitation_token: token
+ )
+ end
+
+ # Helper method to determine invitation type
+ def invitation_type
+ return :person if invitee.present?
+ return :email if invitee_email.present?
+
+ :unknown
+ end
+
+ # Check if this is an invitation for an existing user
+ def for_existing_user?
+ invitation_type == :person
+ end
+
+ # Check if this is an email invitation
+ def for_email?
+ invitation_type == :email
end
private
@@ -63,7 +93,43 @@ def resolve_invitee_person
def invitee_presence
return unless invitee.blank? && self[:invitee_email].to_s.strip.blank?
- errors.add(:invitee_email, :blank)
+ errors.add(:base, 'Either invitee or invitee_email must be present')
+ end
+
+ def invitee_uniqueness_for_event
+ return unless event
+
+ check_duplicate_person_invitation
+ check_duplicate_email_invitation
+ end
+
+ def ensure_community_membership!(person)
+ community = BetterTogether::Community.find_by(host: true)
+
+ return unless community
+
+ # Create community membership for the invitee
+ default_role = BetterTogether::Role.find_by(identifier: 'community_member')
+ community.person_community_memberships.find_or_create_by!(
+ member: person,
+ role: default_role
+ )
+ end
+
+ def check_duplicate_person_invitation
+ return unless invitee.present?
+
+ existing = event.invitations.where(invitee:, status: %w[pending accepted])
+ .where.not(id:)
+ errors.add(:invitee, 'has already been invited to this event') if existing.exists?
+ end
+
+ def check_duplicate_email_invitation
+ return unless invitee_email.present?
+
+ existing = event.invitations.where(invitee_email:, status: %w[pending accepted])
+ .where.not(id:)
+ errors.add(:invitee_email, 'has already been invited to this event') if existing.exists?
end
end
end
diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb
index ac6296de1..9ffa762bd 100644
--- a/app/models/better_together/person.rb
+++ b/app/models/better_together/person.rb
@@ -46,7 +46,9 @@ def self.primary_community_delegation_attrs
has_many :agreements, through: :agreement_participants
has_many :calendars, foreign_key: :creator_id, class_name: 'BetterTogether::Calendar', dependent: :destroy
+
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
+ has_many :event_invitations, class_name: 'BetterTogether::EventInvitation', as: :invitee, dependent: :destroy
has_one :user_identification,
lambda {
diff --git a/app/models/concerns/better_together/primary_community.rb b/app/models/concerns/better_together/primary_community.rb
index 37c667831..06d4e750e 100644
--- a/app/models/concerns/better_together/primary_community.rb
+++ b/app/models/concerns/better_together/primary_community.rb
@@ -48,6 +48,11 @@ def primary_community_extra_attrs
{}
end
+ # Backwards-compatible accessor used in tests and callers expecting a `primary_community` method
+ def primary_community
+ community
+ end
+
def after_record_created; end
def to_s
diff --git a/app/notifiers/better_together/event_invitation_notifier.rb b/app/notifiers/better_together/event_invitation_notifier.rb
index 6f97f6f39..5a03c86cb 100644
--- a/app/notifiers/better_together/event_invitation_notifier.rb
+++ b/app/notifiers/better_together/event_invitation_notifier.rb
@@ -2,32 +2,45 @@
module BetterTogether
class EventInvitationNotifier < ApplicationNotifier # rubocop:todo Style/Documentation
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message
- deliver_by :email, mailer: 'BetterTogether::EventInvitationsMailer', method: :invite, params: :email_params
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications
+ deliver_by :email, mailer: 'BetterTogether::EventInvitationsMailer', method: :invite, params: :email_params,
+ queue: :mailers
- param :invitation
+ required_param :invitation
notification_methods do
- def invitation = params[:invitation]
- def event = invitation.invitable
+ delegate :title, :body, :invitation, :invitable, to: :event
end
+ def invitation = params[:invitation]
+ def invitable = params[:invitable] || invitation&.invitable
+
def title
- I18n.t('better_together.notifications.event_invitation.title',
- event_name: event&.name, default: 'You have been invited to an event')
+ I18n.with_locale(params[:invitation].locale) do
+ I18n.t('better_together.notifications.event_invitation.title',
+ event_name: invitable&.name, default: 'You have been invited to an event')
+ end
end
def body
- I18n.t('better_together.notifications.event_invitation.body',
- event_name: event&.name, default: 'Invitation to %s')
+ I18n.with_locale(params[:invitation].locale) do
+ I18n.t('better_together.notifications.event_invitation.body',
+ event_name: invitable&.name, default: 'Invitation to %s')
+ end
end
def build_message(_notification)
+ # Pass the invitable (event) as the notification url object so views can
+ # link to the event record (consistent with other notifiers that pass
+ # domain objects like agreement/request).
{ title:, body:, url: invitation.url_for_review }
end
def email_params(_notification)
- { invitation: }
+ # Include the invitation and the invitable (event) so mailers and views
+ # have the full context without needing to resolve the invitation.
+ { invitation: params[:invitation], invitable: }
end
end
end
diff --git a/app/notifiers/better_together/event_reminder_notifier.rb b/app/notifiers/better_together/event_reminder_notifier.rb
index e504e3d6b..38a6c382d 100644
--- a/app/notifiers/better_together/event_reminder_notifier.rb
+++ b/app/notifiers/better_together/event_reminder_notifier.rb
@@ -2,11 +2,13 @@
module BetterTogether
# Notifies attendees when an event is approaching
- class EventReminderNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config|
+ class EventReminderNotifier < ApplicationNotifier # rubocop:todo Metrics/ClassLength
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications do |config|
config.if = -> { should_notify? }
end
- deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_reminder, params: :email_params do |config|
+ deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_reminder, params: :email_params,
+ queue: :mailers do |config|
config.wait = 15.minutes
config.if = -> { send_email_notification? }
end
diff --git a/app/notifiers/better_together/event_update_notifier.rb b/app/notifiers/better_together/event_update_notifier.rb
index ebf2d479a..b37dda083 100644
--- a/app/notifiers/better_together/event_update_notifier.rb
+++ b/app/notifiers/better_together/event_update_notifier.rb
@@ -3,10 +3,12 @@
module BetterTogether
# Notifies attendees when an event is updated
class EventUpdateNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config|
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications do |config|
config.if = -> { should_notify? }
end
- deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_update, params: :email_params do |config|
+ deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_update, params: :email_params,
+ queue: :mailers do |config|
config.if = -> { recipient_has_email? && should_notify? }
end
diff --git a/app/notifiers/better_together/joatu/agreement_notifier.rb b/app/notifiers/better_together/joatu/agreement_notifier.rb
index 74f8dc0bb..b3f0e5ed4 100644
--- a/app/notifiers/better_together/joatu/agreement_notifier.rb
+++ b/app/notifiers/better_together/joatu/agreement_notifier.rb
@@ -4,11 +4,12 @@ module BetterTogether
module Joatu
# Sends notifications when a new agreement is created
class AgreementNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications
deliver_by :email,
mailer: 'BetterTogether::JoatuMailer',
method: :agreement_created,
- params: :email_params do |config|
+ params: :email_params, queue: :mailers do |config|
config.if = -> { recipient.email.present? && recipient.notification_preferences['notify_by_email'] }
end
diff --git a/app/notifiers/better_together/joatu/agreement_status_notifier.rb b/app/notifiers/better_together/joatu/agreement_status_notifier.rb
index 09e783cfc..1fb273e05 100644
--- a/app/notifiers/better_together/joatu/agreement_status_notifier.rb
+++ b/app/notifiers/better_together/joatu/agreement_status_notifier.rb
@@ -4,10 +4,11 @@ module BetterTogether
module Joatu
# Notifies offer and request creators when an agreement status changes
class AgreementStatusNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications
deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :agreement_status_changed,
- params: :email_params do |config|
+ params: :email_params, queue: :mailers do |config|
config.if = -> { send_email_notification? }
end
diff --git a/app/notifiers/better_together/joatu/match_notifier.rb b/app/notifiers/better_together/joatu/match_notifier.rb
index d8aabd603..f9788f5cf 100644
--- a/app/notifiers/better_together/joatu/match_notifier.rb
+++ b/app/notifiers/better_together/joatu/match_notifier.rb
@@ -4,10 +4,12 @@ module BetterTogether
module Joatu
# Notifies creators when a new offer or request matches
class MatchNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config|
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications do |config|
config.if = -> { should_notify? }
end
- deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params do |config|
+ deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params,
+ queue: :mailers do |config|
config.if = -> { recipient_has_email? && should_notify? }
end
diff --git a/app/notifiers/better_together/new_message_notifier.rb b/app/notifiers/better_together/new_message_notifier.rb
index 047a963ae..9a614f538 100644
--- a/app/notifiers/better_together/new_message_notifier.rb
+++ b/app/notifiers/better_together/new_message_notifier.rb
@@ -3,10 +3,11 @@
module BetterTogether
# Uses Noticed gem to create and dispatch notifications for new messages
class NewMessageNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications
# deliver_by :action_cable, channel: 'BetterTogether::MessagesChannel', message: :build_message
deliver_by :email, mailer: 'BetterTogether::ConversationMailer', method: :new_message_notification,
- params: :email_params do |config|
+ params: :email_params, queue: :mailers do |config|
config.wait = 15.minutes
config.if = -> { send_email_notification? }
end
diff --git a/app/notifiers/better_together/page_authorship_notifier.rb b/app/notifiers/better_together/page_authorship_notifier.rb
index f8567c813..605e0f138 100644
--- a/app/notifiers/better_together/page_authorship_notifier.rb
+++ b/app/notifiers/better_together/page_authorship_notifier.rb
@@ -3,12 +3,13 @@
module BetterTogether
# Notifies a person when added to or removed from a Page as an author
class PageAuthorshipNotifier < ApplicationNotifier
- deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message
+ deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message,
+ queue: :notifications
deliver_by :email,
mailer: 'BetterTogether::AuthorshipMailer',
method: :authorship_changed_notification,
- params: :email_params do |config|
+ params: :email_params, queue: :mailers do |config|
config.wait = 15.minutes
config.if = -> { send_email_notification? }
end
diff --git a/app/policies/better_together/application_policy.rb b/app/policies/better_together/application_policy.rb
index c52120b72..5f3643a59 100644
--- a/app/policies/better_together/application_policy.rb
+++ b/app/policies/better_together/application_policy.rb
@@ -2,12 +2,13 @@
module BetterTogether
class ApplicationPolicy # rubocop:todo Style/Documentation
- attr_reader :user, :record, :agent
+ attr_reader :user, :record, :agent, :invitation_token
- def initialize(user, record)
+ def initialize(user, record, invitation_token: nil)
@user = user
@agent = user&.person
@record = record
+ @invitation_token = invitation_token
end
def index?
@@ -39,12 +40,13 @@ def destroy?
end
class Scope # rubocop:todo Style/Documentation
- attr_reader :user, :scope, :agent
+ attr_reader :user, :scope, :agent, :invitation_token
- def initialize(user, scope)
+ def initialize(user, scope, invitation_token: nil)
@user = user
@agent = user&.person
@scope = scope
+ @invitation_token = invitation_token
end
def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
diff --git a/app/policies/better_together/event_invitation_policy.rb b/app/policies/better_together/event_invitation_policy.rb
index beae04a8f..9f549ba51 100644
--- a/app/policies/better_together/event_invitation_policy.rb
+++ b/app/policies/better_together/event_invitation_policy.rb
@@ -2,9 +2,13 @@
module BetterTogether
class EventInvitationPolicy < ApplicationPolicy # rubocop:todo Style/Documentation
- # Only platform managers may create event invitations for now
def create?
- user.present? && permitted_to?('manage_platform')
+ return false unless user.present?
+
+ # Event creator and hosts can invite people
+ return true if allowed_on_event?
+
+ permitted_to?('manage_platform') || event_host_member?
end
def destroy?
@@ -17,7 +21,30 @@ def resend?
class Scope < Scope # rubocop:todo Style/Documentation
def resolve
- scope
+ return scope.none unless user.present?
+ return scope.all if permitted_to?('manage_platform')
+
+ # Users see invitations for events they can manage
+ event_invitations_scope
+ end
+
+ private
+
+ def event_invitations_scope
+ scope.joins(:invitable)
+ .where(better_together_invitations: { invitable_type: 'BetterTogether::Event' })
+ .where(manageable_events_condition)
+ end
+
+ def manageable_events_condition
+ [
+ 'better_together_events.creator_id = ? OR ' \
+ 'EXISTS (SELECT 1 FROM better_together_event_hosts ' \
+ 'WHERE better_together_event_hosts.event_id = better_together_events.id ' \
+ 'AND better_together_event_hosts.host_type = ? ' \
+ 'AND better_together_event_hosts.host_id = ?)',
+ user.person&.id, 'BetterTogether::Person', user.person&.id
+ ]
end
end
@@ -31,8 +58,14 @@ def allowed_on_event?
return true if permitted_to?('manage_platform')
ep = BetterTogether::EventPolicy.new(user, event)
- # Organizer-only: event hosts or event creator (exclude platform-manager-only path)
+ # Event hosts or event creator
ep.event_host_member? || (user.present? && event.creator == agent)
end
+
+ def event_host_member?
+ return false unless user&.person && record.invitable.is_a?(BetterTogether::Event)
+
+ record.invitable.event_hosts.exists?(host_type: 'BetterTogether::Person', host_id: user.person.id)
+ end
end
end
diff --git a/app/policies/better_together/event_policy.rb b/app/policies/better_together/event_policy.rb
index b24f7cd9a..9af480fd0 100644
--- a/app/policies/better_together/event_policy.rb
+++ b/app/policies/better_together/event_policy.rb
@@ -8,7 +8,11 @@ def index?
end
def show?
- (record.privacy_public? && record.starts_at.present?) || creator_or_manager || event_host_member?
+ (record.privacy_public? && record.starts_at.present?) ||
+ creator_or_manager ||
+ event_host_member? ||
+ invitation? ||
+ valid_invitation_token?
end
def ics?
@@ -27,6 +31,19 @@ def destroy?
creator_or_manager || event_host_member?
end
+ # RSVP policy methods
+ def rsvp_interested?
+ show? && user.present?
+ end
+
+ def rsvp_going?
+ show? && user.present?
+ end
+
+ def rsvp_cancel?
+ show? && user.present?
+ end
+
def event_host_member?
return false unless user.present?
@@ -75,12 +92,37 @@ def permitted_query # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
)
end
+ if agent.event_attendances.any?
+ event_ids = agent.event_attendances.pluck(:event_id)
+ query = query.or(
+ events_table[:id].in(event_ids)
+ )
+ end
+
+ if agent.event_invitations.any?
+ event_ids = agent.event_invitations.pluck(:invitable_id)
+ query = query.or(
+ events_table[:id].in(event_ids)
+ )
+ end
+
query
else
- # Events must have a start time to be shown to people who aren't conencted to the event
+ # Events must have a start time to be shown to people who aren't connected to the event
query = query.and(events_table[:starts_at].not_eq(nil))
end
+ # Add logic for invitation token access
+ if invitation_token.present?
+ invitation_table = ::BetterTogether::EventInvitation.arel_table
+ event_ids_with_valid_invitations = invitation_table
+ .where(invitation_table[:token].eq(invitation_token))
+ .where(invitation_table[:status].eq('pending'))
+ .project(:invitable_id)
+
+ query = query.or(events_table[:id].in(event_ids_with_valid_invitations))
+ end
+
query
end
# rubocop:enable Metrics/MethodLength
@@ -89,5 +131,27 @@ def permitted_query # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
def creator_or_manager
user.present? && (record.creator == agent || permitted_to?('manage_platform'))
end
+
+ def invitation?
+ return false unless agent.present?
+
+ # Check if the current person has an invitation to this event
+ BetterTogether::EventInvitation.exists?(
+ invitable: record,
+ invitee: agent
+ )
+ end
+
+ # Check if there's a valid invitation token for this event
+ def valid_invitation_token?
+ return false unless invitation_token.present?
+
+ invitation = BetterTogether::EventInvitation.find_by(
+ token: invitation_token,
+ invitable: record
+ )
+
+ invitation.present? && invitation.status_pending?
+ end
end
end
diff --git a/app/policies/better_together/person_policy.rb b/app/policies/better_together/person_policy.rb
index e8150cd2b..1c5d04a07 100644
--- a/app/policies/better_together/person_policy.rb
+++ b/app/policies/better_together/person_policy.rb
@@ -68,6 +68,13 @@ def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
# Add people with direct interactions (blocked users, conversation participants, etc.)
query = query.or(people_table[:id].in(interaction_person_ids)) if interaction_person_ids.any?
+ # Get IDs of people the current user has blocked or been blocked by
+ blocked_ids = agent.person_blocks.pluck(:blocked_id)
+ blocker_ids = BetterTogether::PersonBlock.where(blocked_id: agent.id).pluck(:blocker_id)
+ excluded_ids = blocked_ids + blocker_ids
+
+ query = query.and(people_table[:id].not_in(excluded_ids)) if excluded_ids.any?
+
base_scope.where(query).distinct
end
@@ -93,18 +100,13 @@ def shared_community_member_ids # rubocop:todo Metrics/MethodLength
end
end
- def interaction_person_ids # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
+ def interaction_person_ids # rubocop:todo Metrics/MethodLength
return @interaction_person_ids if defined?(@interaction_person_ids)
@interaction_person_ids = if agent.present?
ids = []
- # People the current user has blocked or been blocked by
- blocked_ids = agent.person_blocks.pluck(:blocked_id)
- blocker_ids = BetterTogether::PersonBlock.where(blocked_id: agent.id).pluck(:blocker_id) # rubocop:disable Layout/LineLength
- ids.concat(blocked_ids + blocker_ids)
-
- # People in conversations with the current user
+ # People in conversations with the current user, excluding blocked people
if defined?(BetterTogether::Conversation) && defined?(BetterTogether::ConversationParticipant)
conversation_ids = BetterTogether::ConversationParticipant
.where(person_id: agent.id)
diff --git a/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb b/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb
new file mode 100644
index 000000000..c41ce72a2
--- /dev/null
+++ b/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb
@@ -0,0 +1,10 @@
+
+
+<%= render layout: 'better_together/notifications/notification',
+ locals: { notification: notification,
+ notification_title: notification.title,
+ notification_url: notification.invitable } do %>
+
+ <%= notification.body %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/better_together/event_invitations_mailer/invite.html.erb b/app/views/better_together/event_invitations_mailer/invite.html.erb
index 50bfc078e..619210022 100644
--- a/app/views/better_together/event_invitations_mailer/invite.html.erb
+++ b/app/views/better_together/event_invitations_mailer/invite.html.erb
@@ -1,6 +1,57 @@
-
<%= t('.greeting', default: 'Hello,') %>
-
<%= t('.invited_html', default: 'You have been invited to the event %{event}.', event: @event&.name) %>
<%= link_to event, class: 'text-decoration-none text-muted event-link' do %>
-
- <%= t('better_together.people.calendar.no_events_description',
+ <%= t('better_together.people.calendar.no_events_description',
default: 'Events will appear here when you RSVP as "Going" to events.') %>
diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb
index 7e164ef33..d679211c0 100644
--- a/config/initializers/geocoder.rb
+++ b/config/initializers/geocoder.rb
@@ -2,6 +2,8 @@
# config/initializers/geocoder.rb
Geocoder.configure(
+ # Use test lookup in development/test to avoid external API calls
+ lookup: Rails.env.production? ? :nominatim : :test,
always_raise: :all,
# geocoding service request timeout, in seconds (default 3):
timeout: 5,
@@ -12,3 +14,48 @@
# caching (see Caching section below for details):
cache: Geocoder::CacheStore::Generic.new(Rails.cache, {})
)
+
+# Configure test geocoding results for development/test environments
+unless Rails.env.production?
+ Geocoder::Lookup::Test.add_stub(
+ 'New York, NY', [
+ {
+ 'latitude' => 40.7143528,
+ 'longitude' => -74.0059731,
+ 'address' => 'New York, NY, USA',
+ 'state' => 'New York',
+ 'state_code' => 'NY',
+ 'country' => 'United States',
+ 'country_code' => 'US'
+ }
+ ]
+ )
+
+ Geocoder::Lookup::Test.add_stub(
+ 'San Francisco, CA', [
+ {
+ 'latitude' => 37.7749295,
+ 'longitude' => -122.4194155,
+ 'address' => 'San Francisco, CA, USA',
+ 'state' => 'California',
+ 'state_code' => 'CA',
+ 'country' => 'United States',
+ 'country_code' => 'US'
+ }
+ ]
+ )
+
+ # Default stub for any address not specifically configured
+ Geocoder::Lookup::Test.set_default_stub(
+ [
+ {
+ 'latitude' => 0.0,
+ 'longitude' => 0.0,
+ 'address' => 'Test Address',
+ 'state' => 'Test State',
+ 'country' => 'Test Country',
+ 'country_code' => 'TC'
+ }
+ ]
+ )
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6c4f22531..ab6a244a7 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -597,9 +597,9 @@ en:
page_label: Linked Page (optional)
no_terms: No terms defined.
notice:
- title: "View required agreement"
- body: "Please view the full agreement before accepting."
- close: "Close"
+ body: Please view the full agreement before accepting.
+ close: Close
+ title: View required agreement
participant:
pending: pending
show:
@@ -805,10 +805,16 @@ en:
title: Email Addresses
event_invitations_mailer:
invite:
+ event_details: Event Details
greeting: Hello,
- invited_html: You have been invited to the event %{event}.
+ invited_by_html: You were invited by %{inviter_name}.
+ invited_html: You have been invited to the event %{event_name}.
+ need_account_html: You'll need to create an account to accept this invitation
+ and join the event.
review_invitation: Review Invitation
subject: You are invited to an event
+ when: When
+ where: Where
event_mailer:
event_reminder:
description: Description
@@ -996,14 +1002,28 @@ en:
locations: Locations
invitations:
accept: Accept
+ confirm_remove: Are you sure you want to remove this invitation?
decline: Decline
event_name: 'Event:'
event_panel:
title: Invite People
+ external_user: External user
+ invite_by_email: Invite by Email
+ invite_person: Invite Member
+ invitee: Invitee
invitee_email: Email
+ invitee_type: Invitee Type
+ login_to_respond: Please log in to respond to your invitation.
pending: Pending Invitations
+ register_to_respond: Please register to respond to your invitation.
review: Invitation
+ search_people: Search for people...
+ select_person: Select Person
send_invite: Send Invitation
+ title: Event Invitations
+ type:
+ email: Email
+ person: Member
joatu:
agreements:
accept: Accept
@@ -1138,6 +1158,9 @@ en:
labelable:
custom-label-placeholder: Your custom label
loading: Loading...
+ mailer:
+ footer:
+ no_reply: This is an automated message. Please do not reply to this email.
messages:
form:
placeholder: Type your message...
@@ -1288,6 +1311,7 @@ en:
blocked_count:
one: 1 person blocked
other: "%{count} people blocked"
+ zero: No one blocked
blocked_on: Blocked on %{date}
no_blocked_people: You haven't blocked anyone yet.
no_blocked_people_description: When you block someone, they won't be able
@@ -1851,10 +1875,12 @@ en:
blocked: Person was successfully blocked.
unblocked: Person was successfully unblocked.
globals:
+ accepted: Accepted
actions: Actions
add_block: Add block
add_child_item: Add child item
add_member: Add member
+ ago: ago
are_you_sure: Are you sure
back: Back
back_to_list: Back to list
@@ -1862,6 +1888,7 @@ en:
clear: Clear
confirm_delete: Are you sure you want to delete this record?
create: Create
+ declined: Declined
delete: Delete
destroy: Destroy
draft: Draft
@@ -1879,6 +1906,8 @@ en:
no_image: No Image
no_invitee: No invitee
none: None
+ not_sent: Not sent
+ pending: Pending
platform_not_public: The platform is currently private. Please log in to access.
published: Published
remove: Remove
@@ -1886,6 +1915,7 @@ en:
save: Save
sent: Sent
show: Show
+ status: Status
tabs:
about: About
attendees: Attendees
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 7be2c483c..40e872d13 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -282,16 +282,16 @@ es:
string_translations: Traducciones de cadenas
template: Plantilla
better_together/page_authorship_notifier:
- notifications: Notifications
- params: Params
+ notifications: Notificaciones
+ params: Parámetros
record: :activerecord.models.record
- record_type: Record type
+ record_type: Tipo de registro
better_together/page_authorship_notifier/notification:
event: :activerecord.models.event
- read_at: Read at
+ read_at: Leído en
recipient: :activerecord.models.recipient
- recipient_type: Recipient type
- seen_at: Seen at
+ recipient_type: Tipo de destinatario
+ seen_at: Visto en
better_together/person:
authorships: Autorías
community: :activerecord.models.community
@@ -600,9 +600,9 @@ es:
page_label: Página vinculada (opcional)
no_terms: No hay términos definidos.
notice:
- title: "Ver acuerdo requerido"
- body: "Por favor, lea el acuerdo completo antes de aceptarlo."
- close: "Cerrar"
+ body: Por favor, lea el acuerdo completo antes de aceptarlo.
+ close: Cerrar
+ title: Ver acuerdo requerido
participant:
pending: Pendiente
show:
@@ -808,10 +808,16 @@ es:
title: Direcciones de Correo Electrónico
event_invitations_mailer:
invite:
+ event_details: Detalles del Evento
greeting: Hola,
- invited_html: Has sido invitado al evento %{event}.
+ invited_by_html: Fuiste invitado por %{inviter_name}.
+ invited_html: Has sido invitado al evento %{event_name}.
+ need_account_html: Necesitarás crear una cuenta para aceptar esta invitación
+ y unirte al evento.
review_invitation: Revisar invitación
subject: Has sido invitado a un evento
+ when: Cuándo
+ where: Dónde
event_mailer:
event_reminder:
description: Descripción
@@ -1003,14 +1009,28 @@ es:
locations: Ubicaciones
invitations:
accept: Aceptar
+ confirm_remove: "¿Estás seguro de que quieres eliminar esta invitación?"
decline: Rechazar
event_name: 'Evento:'
event_panel:
title: Invitar personas
+ external_user: Usuario externo
+ invite_by_email: Invitar por correo electrónico
+ invite_person: Invitar miembro
+ invitee: Invitado
invitee_email: Correo electrónico
+ invitee_type: Tipo de invitado
+ login_to_respond: Por favor, inicia sesión para responder a tu invitación.
pending: Invitaciones pendientes
+ register_to_respond: Por favor, regístrate para responder a tu invitación.
review: Revisar invitación
+ search_people: Buscar personas...
+ select_person: Seleccionar persona
send_invite: Enviar invitación
+ title: Invitaciones a Eventos
+ type:
+ email: Correo electrónico
+ person: Miembro
joatu:
agreements:
accept: Aceptar
@@ -1108,24 +1128,24 @@ es:
respond_with_request: Respond with Request
response_to_your: Response to your %{type}
responses:
- in_response_to: In response to
+ in_response_to: En respuesta a
search:
labels:
- filter_by_type: Filter by category
- order_by: Order by
- per_page: Per page
- search-term: Search term
- status: Status
- submit: Search
- newest: Newest
- oldest: Oldest
+ filter_by_type: Filtrar por categoría
+ order_by: Ordenar por
+ per_page: Por página
+ search-term: Término de búsqueda
+ status: Estado
+ submit: Buscar
+ newest: Más reciente
+ oldest: Más antiguo
placeholders:
- search: Search by title or description
- title: Search
+ search: Buscar por título o descripción
+ title: Buscar
status:
- any: Any
- closed: Closed
- open: Open
+ any: Cualquiera
+ closed: Cerrado
+ open: Abierto
type:
offer: Offer
request: Request
@@ -1144,6 +1164,10 @@ es:
labelable:
custom-label-placeholder: Tu etiqueta personalizada
loading: Cargando...
+ mailer:
+ footer:
+ no_reply: Este es un mensaje automatizado. Por favor, no respondas a este
+ correo electrónico.
messages:
form:
placeholder: Escribe tu mensaje...
@@ -1294,6 +1318,7 @@ es:
blocked_count:
one: 1 persona bloqueada
other: "%{count} personas bloqueadas"
+ zero: Aún no has bloqueado a nadie
blocked_on: Bloqueado el %{date}
no_blocked_people: Aún no has bloqueado a nadie.
no_blocked_people_description: Cuando bloqueas a alguien, no podrán interactuar
@@ -1848,16 +1873,16 @@ es:
flash:
checklist_item:
update_failed: Failed to update checklist item.
- updated: Checklist item updated.
+ updated: Elemento de la lista de verificación actualizado.
generic:
created: "%{resource} se creó correctamente."
- deleted: Deleted
+ deleted: Eliminado
destroyed: "%{resource} se eliminó correctamente."
error_create: Error al crear %{resource}.
error_remove: No se pudo eliminar %{resource}.
queued: "%{resource} se ha puesto en cola para enviar."
removed: "%{resource} se eliminó correctamente."
- unauthorized: No estás autorizado para realizar esta acción
+ unauthorized: No autorizado
updated: "%{resource} se actualizó correctamente."
joatu:
agreement:
@@ -1870,17 +1895,20 @@ es:
blocked: La persona fue bloqueada correctamente.
unblocked: La persona fue desbloqueada correctamente.
globals:
+ accepted: Aceptado
actions: Acciones
add_block: Agregar bloque
add_child_item: Agregar elemento hijo
add_member: Agregar miembro
- are_you_sure: Are you sure
+ ago: hace
+ are_you_sure: "¿Estás seguro?"
back: Atrás
back_to_list: Volver a la lista
cancel: Cancelar
clear: Borrar
confirm_delete: "¿Está seguro de que desea eliminar este registro?"
create: Crear
+ declined: Rechazado
delete: Eliminar
destroy: Destruir
draft: Borrador
@@ -1898,6 +1926,8 @@ es:
no_image: Sin imagen
no_invitee: Sin invitado
none: Ninguno
+ not_sent: No enviado
+ pending: Pendiente
platform_not_public: La plataforma es actualmente privada. Por favor, inicie sesión
para acceder.
published: Publicado
@@ -1906,6 +1936,7 @@ es:
save: Guardar
sent: Enviado
show: Mostrar
+ status: Estado
tabs:
about: Acerca de
attendees: Asistentes
@@ -1915,7 +1946,7 @@ es:
members: Miembros
'true': Sí
undo_clear: Deshacer el borrado
- update: Update
+ update: Actualizar
url: Url
view: Ver
visible: Visible
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index bdc76d421..0b416916b 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -283,15 +283,15 @@ fr:
template: Modèle
better_together/page_authorship_notifier:
notifications: Notifications
- params: Params
+ params: Paramètres
record: :activerecord.models.record
- record_type: Record type
+ record_type: Type d'enregistrement
better_together/page_authorship_notifier/notification:
event: :activerecord.models.event
- read_at: Read at
+ read_at: Lu à
recipient: :activerecord.models.recipient
- recipient_type: Recipient type
- seen_at: Seen at
+ recipient_type: Type de destinataire
+ seen_at: Vu à
better_together/person:
authorships: Auteurs
community: :activerecord.models.community
@@ -602,9 +602,9 @@ fr:
page_label: Page liée (optionnel)
no_terms: Aucune condition définie.
notice:
- title: "Afficher l'accord requis"
- body: "Veuillez lire l'accord complet avant de l'accepter."
- close: "Fermer"
+ body: Veuillez lire l'accord complet avant de l'accepter.
+ close: Fermer
+ title: Afficher l'accord requis
participant:
pending: en attente
show:
@@ -771,7 +771,7 @@ fr:
à retrouver cette conversation plus tard
index:
conversations: Conversations
- new: New
+ new: Nouveau
layout_with_sidebar:
active_conversations: Conversations actives
add_participants: Ajouter des participants
@@ -814,10 +814,16 @@ fr:
title: Adresses e-mail
event_invitations_mailer:
invite:
+ event_details: Détails de l'Événement
greeting: Bonjour,
- invited_html: Vous avez été invité à l'événement %{event}.
+ invited_by_html: Vous avez été invité par %{inviter_name}.
+ invited_html: Vous avez été invité à l'événement %{event_name}.
+ need_account_html: Vous devrez créer un compte pour accepter cette invitation
+ et rejoindre l'événement.
review_invitation: Voir l'invitation
subject: Vous êtes invité à un événement
+ when: Quand
+ where: Où
event_mailer:
event_reminder:
description: Description
@@ -1008,14 +1014,28 @@ fr:
locations: Emplacements
invitations:
accept: Accepter
+ confirm_remove: Êtes-vous sûr de vouloir supprimer cette invitation ?
decline: Refuser
event_name: 'Événement :'
event_panel:
title: Inviter des personnes
+ external_user: Utilisateur externe
+ invite_by_email: Inviter par e-mail
+ invite_person: Inviter un membre
+ invitee: Invité(e)
invitee_email: E-mail
+ invitee_type: Type d'invité
+ login_to_respond: Veuillez vous connecter pour répondre à votre invitation.
pending: Invitations en attente
+ register_to_respond: Veuillez vous inscrire pour répondre à votre invitation.
review: Invitation
+ search_people: Rechercher des personnes...
+ select_person: Sélectionner une personne
send_invite: Envoyer l'invitation
+ title: Invitations aux Événements
+ type:
+ email: E-mail
+ person: Membre
joatu:
agreements:
accept: Accepter
@@ -1114,24 +1134,24 @@ fr:
respond_with_request: Respond with Request
response_to_your: Response to your %{type}
responses:
- in_response_to: In response to
+ in_response_to: En réponse à
search:
labels:
- filter_by_type: Filter by category
- order_by: Order by
- per_page: Per page
- search-term: Search term
- status: Status
- submit: Search
- newest: Newest
- oldest: Oldest
+ filter_by_type: Filtrer par catégorie
+ order_by: Trier par
+ per_page: Par page
+ search-term: Terme de recherche
+ status: Statut
+ submit: Rechercher
+ newest: Plus récent
+ oldest: Plus ancien
placeholders:
- search: Search by title or description
- title: Search
+ search: Rechercher par titre ou description
+ title: Rechercher
status:
- any: Any
- closed: Closed
- open: Open
+ any: Tous
+ closed: Fermé
+ open: Ouvert
type:
offer: Offer
request: Request
@@ -1150,6 +1170,9 @@ fr:
labelable:
custom-label-placeholder: Votre étiquette personnalisée
loading: Chargement...
+ mailer:
+ footer:
+ no_reply: Ceci est un message automatisé. Veuillez ne pas répondre à cet e-mail.
messages:
form:
placeholder: Tapez votre message...
@@ -1300,6 +1323,7 @@ fr:
blocked_count:
one: 1 personne bloquée
other: "%{count} personnes bloquées"
+ zero: Aucun personne bloquée
blocked_on: Bloqué le %{date}
no_blocked_people: Vous n'avez encore bloqué personne.
no_blocked_people_description: Lorsque vous bloquez quelqu'un, il ne pourra
@@ -1861,13 +1885,13 @@ fr:
updated: Élément de la liste mis à jour.
generic:
created: "%{resource} a été créé avec succès."
- deleted: Deleted
+ deleted: Supprimé
destroyed: "%{resource} a été supprimé avec succès."
error_create: Erreur lors de la création de %{resource}.
error_remove: Échec de la suppression de %{resource}.
queued: "%{resource} a été mis en file d'attente pour l'envoi."
removed: "%{resource} a été supprimé avec succès."
- unauthorized: Unauthorized
+ unauthorized: Non autorisé
updated: "%{resource} a été mis à jour avec succès."
joatu:
agreement:
@@ -1880,17 +1904,20 @@ fr:
blocked: La personne a été bloquée avec succès.
unblocked: La personne a été débloquée avec succès.
globals:
+ accepted: Accepté
actions: Actions
add_block: Ajouter un bloc
add_child_item: Ajouter un élément enfant
add_member: Ajouter un membre
- are_you_sure: Are you sure
+ ago: il y a
+ are_you_sure: Êtes-vous sûr ?
back: Retour
back_to_list: Retour à la liste
- cancel: Cancel
+ cancel: Annuler
clear: Effacer
confirm_delete: Êtes-vous sûr de vouloir supprimer cet enregistrement ?
- create: Create
+ create: Créer
+ declined: Refusé
delete: Supprimer
destroy: Détruire
draft: Brouillon
@@ -1908,6 +1935,8 @@ fr:
no_image: Pas d'image
no_invitee: Pas d'invité
none: Aucun
+ not_sent: Non envoyé
+ pending: En attente
platform_not_public: La plateforme est actuellement privée. Veuillez vous connecter
pour accéder.
published: Publié
@@ -1916,6 +1945,7 @@ fr:
save: Enregistrer
sent: Envoyé
show: Afficher
+ status: Statut
tabs:
about: À propos
attendees: Participants
diff --git a/config/routes.rb b/config/routes.rb
index 5b292a6e7..360286ee0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -57,6 +57,9 @@
resources :events, except: %i[index show] do
resources :invitations, only: %i[create destroy], module: :events do
+ collection do
+ get :available_people
+ end
member do
put :resend
end
diff --git a/db/migrate/20250906172911_increase_token_limit_for_invitations.rb b/db/migrate/20250906172911_increase_token_limit_for_invitations.rb
new file mode 100644
index 000000000..0defc95e1
--- /dev/null
+++ b/db/migrate/20250906172911_increase_token_limit_for_invitations.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Migration to increase token column limit for invitation models from 24 to 64 characters
+# to support longer, more secure tokens for better security
+class IncreaseTokenLimitForInvitations < ActiveRecord::Migration[7.1]
+ def up
+ # Increase token column limit from 24 to 64 characters to support longer, more secure tokens
+ change_column :better_together_invitations, :token, :string, limit: 64, null: false
+ change_column :better_together_platform_invitations, :token, :string, limit: 64, null: false
+ end
+
+ def down
+ # Revert back to 24 character limit (note: this could cause data loss if tokens are longer)
+ change_column :better_together_invitations, :token, :string, limit: 24, null: false
+ change_column :better_together_platform_invitations, :token, :string, limit: 24, null: false
+ end
+end
diff --git a/docs/community_organizers/event_invitations_management.md b/docs/community_organizers/event_invitations_management.md
new file mode 100644
index 000000000..bb1a7e4f5
--- /dev/null
+++ b/docs/community_organizers/event_invitations_management.md
@@ -0,0 +1,58 @@
+# Event Invitations Management (Organizers & Hosts)
+
+This guide covers how organizers and event hosts invite people to events, manage invitation delivery, and track RSVP outcomes.
+
+## Permissions & Access
+
+- You must be the event creator, a platform manager, or a host representative for the event to manage invitations.
+- Host representative status is determined by the `EventPolicy` (`event_host_member?`).
+
+## Inviting Members vs External Emails
+
+From the event page’s “Invitations” panel:
+
+- Invite Member:
+ - Use the “Invite Member” tab to select an existing person.
+ - The selector uses `available_people` to show members not already invited and with an email.
+ - Locale defaults from the person if set.
+- Invite by Email:
+ - Use the “Invite by Email” tab to invite someone via email address.
+ - Set a preferred locale for the invitation.
+
+## Delivery & Throttling
+
+- Member invitations send a Noticed notification (in-app) and may send an email.
+- Email invitations send via `EventInvitationsMailer` only.
+- To prevent spam, repeated sends are throttled if the last send was within 15 minutes.
+- You can resend an invitation from the invitations table when appropriate.
+
+## Managing Invitations
+
+- View all invitations in the “Invitations” section of the event page.
+- Status lifecycle: `pending` → `accepted` or `declined`.
+- Duplicate protection prevents inviting the same person/email again while pending/accepted.
+- You can delete invitations that are no longer needed.
+
+## Private Platform Access via Token
+
+- Invitation links include a token that grants access to the specific event on private platforms.
+- Invitees who aren’t signed in will be prompted to sign in or register; the token is saved to complete the response after authentication.
+
+## RSVP Effects
+
+- Accepting an event invitation automatically:
+ - Ensures the invitee is a member of the host community (standard member role).
+ - Sets RSVP to “Going” and creates a calendar entry for the invitee.
+
+## Best Practices
+
+- Use “Invite Member” when possible (richer delivery + better tracking).
+- Stagger resends: avoid sending the same invite within 15 minutes.
+- Monitor the “Attendees” section for Going/Interested counts to plan capacity.
+- Include clear descriptions and schedule times so RSVP is available.
+
+## Troubleshooting
+
+- Duplicate errors: someone was already invited or accepted — review the invitations table first.
+- No results in “Select Person”: the person may lack an email address; ask them to add one to their profile.
+- Invitee can’t open link on private platform: confirm the token matches the correct event and is not expired.
diff --git a/docs/developers/systems/event_invitations_and_attendance.md b/docs/developers/systems/event_invitations_and_attendance.md
new file mode 100644
index 000000000..40458bba1
--- /dev/null
+++ b/docs/developers/systems/event_invitations_and_attendance.md
@@ -0,0 +1,225 @@
+# Event Invitations & Attendance
+
+This document provides an end-to-end, in-depth reference for event invitations and attendance (RSVP) in the Better Together Community Engine. It covers data models, controller flows, access control, invitation token handling, email and in-app notifications, RSVP life cycle, and how these pieces interact with platform privacy.
+
+## Overview
+
+- Event invitations allow organizers and hosts to invite existing members or external emails to a specific event.
+- Invitations support secure token links for review, acceptance, or decline — including first-time registration flows.
+- Acceptance automatically ensures community membership and sets RSVP to “going”, creating a calendar entry.
+- Attendance (RSVP) supports two statuses: “interested” and “going”, with cancellation removing the attendance record and calendar entry.
+- Invitation tokens permit access to otherwise private content for the specific invited event while preserving platform privacy.
+
+## Core Models
+
+- `BetterTogether::Invitation`: Polymorphic base model with `invitable`, `inviter`, optional `invitee`, `role`, `status` (string enum: pending, accepted, declined), `token` (secure), validity window, and timestamps.
+- `BetterTogether::EventInvitation < Invitation`: Invitation specialization for events.
+ - Status values: `pending`, `accepted`, `declined` (string enum).
+ - Validates presence/inclusion of `locale` and requires one of `invitee` or `invitee_email`.
+ - Prevents duplicate invitations for a given event for either the same `invitee` or the same `invitee_email` while status is in `pending` or `accepted`.
+ - Methods:
+ - `event`: alias to `invitable`.
+ - `url_for_review`: event URL with `invitation_token` param to support review page and access.
+ - `for_existing_user?` / `for_email?`: invitation mode helpers.
+ - `accept!(invitee_person:)`: sets status, ensures community membership, creates/updates `EventAttendance` to `going`.
+ - `decline!`: sets status to `declined`.
+- `BetterTogether::EventAttendance`: RSVP record for a `person` and `event` with string enum statuses: `interested`, `going`.
+ - Constraints:
+ - Unique per person/event.
+ - Event must be scheduled (no RSVP on drafts).
+ - Side effects:
+ - On `going`, creates a `CalendarEntry` in the person’s primary calendar.
+ - On status change away from `going` or destroy, removes the calendar entry.
+
+## Data Model Diagram (Invitations + Attendance)
+
+```mermaid
+%% See separate Mermaid source file for editing: docs/diagrams/source/events_invitations_schema_erd.mmd
+```
+
+**Diagram Files:**
+- 📊 Mermaid Source: ../diagrams/source/events_invitations_schema_erd.mmd
+- 🖼️ PNG Export: ../diagrams/exports/png/events_invitations_schema_erd.png
+- 🎯 SVG Export: ../diagrams/exports/svg/events_invitations_schema_erd.svg
+
+## Invitation Creation & Delivery
+
+Event organizers and host representatives create invitations from the event page (Invitations panel). Two modes are supported:
+
+- Invite existing member:
+ - Provide `invitee_id` (a `BetterTogether::Person`), which auto-fills `invitee_email` and `locale` from the person record.
+ - Delivery: Noticed notification to the user (`EventInvitationNotifier`) and optional email via `EventInvitationsMailer`.
+- Invite by email:
+ - Provide `invitee_email` and target `locale`.
+ - Delivery: Email sent to the external address via `EventInvitationsMailer`.
+
+Simple throttling prevents resending an invitation more than once within 15 minutes (`last_sent` timestamp check). Resend is supported for pending invitations.
+
+### Controller Endpoints (Organizer/Host)
+
+- `POST /events/:event_id/invitations` (create):
+ - Authorization: `EventInvitationPolicy#create?` (organizer/host scope).
+ - Parameters: `invitee_id` or `invitee_email`, optional `valid_from`, `valid_until`, `locale`, `role_id`.
+ - Behavior: builds `EventInvitation`, sets `status: 'pending'`, `inviter`, and default `valid_from`.
+ - Delivery: Noticed/email depending on invitation type; updates `last_sent`.
+
+- `PUT /events/:event_id/invitations/:id/resend` (resend):
+ - Authorization: same as create; respects resend throttling.
+
+- `DELETE /events/:event_id/invitations/:id` (destroy):
+ - Authorization: allowed for organizers/hosts.
+
+- `GET /events/:event_id/invitations/available_people` (AJAX):
+ - Returns up to 20 non-invited people with an email address; supports search term.
+ - Uses policy scope over `Person` and joins on user/contact email.
+
+## Public Invitation Review & Response
+
+Public review and response routes are token-based:
+
+- `GET /invitations/:token` → `InvitationsController#show`
+- `POST /invitations/:token/accept` → `#accept`
+- `POST /invitations/:token/decline` → `#decline`
+
+Behavior:
+- Finds `Invitation.pending.not_expired` by token; returns 404 if missing.
+- For `accept`:
+ - Requires authentication; if not signed in, stores token in session and redirects to sign-in/registration based on `invitee_email` lookup.
+ - If `invitee` is bound, enforces that the logged-in person matches; otherwise, binds the invitation to the current person.
+ - Calls `accept!` if available (for `EventInvitation`) or sets status to `accepted`.
+ - Redirects to the invitable (event) after acceptance.
+- For `decline`:
+ - Calls `decline!` (or sets status) and redirects to event if available, else home.
+
+## Access Control & Privacy with Invitation Tokens
+
+Invitation tokens grant limited access only to the specific event to which they belong. The platform may still require login for other content.
+
+Key elements:
+
+- `EventsController#check_platform_privacy` augmentation:
+ - For private platforms and unauthenticated users, extracts an `invitation_token` from params/session.
+ - Validates token for an `EventInvitation` that matches the requested event.
+ - On valid, stores token and locale in session and allows access to the event page.
+ - On invalid/expired, redirects to sign-in.
+
+- `EventPolicy#show?`:
+ - Permits if event is public and scheduled, or if creator/manager/host member, or if the current person holds an invitation, or if there is a valid invitation token.
+
+- `ApplicationPolicy::Scope` for events:
+ - Includes events visible through valid `invitation_token` in the scope.
+
+### Token Access Flow Diagram
+
+```mermaid
+%% See separate Mermaid source file for editing: docs/diagrams/source/events_access_via_invitation_token.mmd
+```
+
+**Diagram Files:**
+- 📊 Mermaid Source: ../diagrams/source/events_access_via_invitation_token.mmd
+- 🖼️ PNG Export: ../diagrams/exports/png/events_access_via_invitation_token.png
+- 🎯 SVG Export: ../diagrams/exports/svg/events_access_via_invitation_token.svg
+
+## Notifications
+
+- `BetterTogether::EventInvitationNotifier` (Noticed):
+ - Channels: ActionCable (in-app) and Email (`EventInvitationsMailer`).
+ - Email uses parameterized mailer with `invitation` and `invitable` context.
+ - Message includes localized title/body and the invitation review URL.
+
+- `BetterTogether::EventInvitationsMailer`:
+ - Sends to `invitee_email` using the invitation’s `locale`.
+ - Subject includes event name with localized fallback.
+
+## RSVP (Attendance)
+
+The RSVP system is centered on `EventAttendance` and is managed through member routes on the `Event` resource:
+
+- `POST /events/:id/rsvp_interested`
+- `POST /events/:id/rsvp_going`
+- `DELETE /events/:id/rsvp_cancel`
+
+Constraints and behavior:
+- Only available when the event is scheduled (has `starts_at`).
+- Requires authentication for all RSVP actions.
+- Authorization uses `EventPolicy#show?` followed by `EventAttendancePolicy` for create/update.
+- On `going`, a calendar entry is created; on cancel or switching away from `going`, the entry is removed.
+
+Existing diagrams cover the RSVP journey and reminder scheduling:
+- RSVP Flow: ../diagrams/source/events_rsvp_flow.mmd
+- Reminder Timeline: ../diagrams/source/events_reminders_timeline.mmd
+
+## Organizer UI
+
+- Invitations Panel on Event Show:
+ - Tabs include Attendees and Invitations.
+ - Invite by Member (`invitee_id`) or by Email (`invitee_email`).
+ - View invitations table with type (member/email), status, resend/delete actions.
+ - Search available people endpoint to prevent re-inviting and to filter by email presence.
+
+## Security & Validation
+
+- String enums: all statuses are strings for human-readable DB contents.
+- Duplicate protection: event invitation uniqueness enforced for both `invitee` and `invitee_email` while status is pending/accepted.
+- Privacy: invitation tokens scoped to a single event; do not grant broad platform access.
+- Session token storage: invitation token and locale persisted for acceptance and consistent access; tokens expire per validity window.
+- Safe dynamic resolution: no unsafe constantization of user input; controllers use allow-lists for host assignment and standard strong parameters.
+- Authorization: `Pundit` policies on events, attendances, and invitations.
+
+## Performance Considerations
+
+- Organizer views preload invitations, invitees, and inviters to minimize N+1 queries.
+- Event show preloads hosts, categories, attendances, translations, and cover image attachment.
+- RSVP and invitation actions redirect back to the event to keep interaction snappy.
+- Noticed notifications and email delivery run asynchronously.
+
+## Troubleshooting
+
+- Invitation token shows 404 on private platform:
+ - Ensure the token matches the requested event and is still pending/not expired.
+ - Verify session is storing `event_invitation_token` and that controller privacy check is triggered.
+
+- Invitee required error:
+ - Provide either `invitee_id` (member) or `invitee_email` (external) — one must be present.
+
+- Duplicate invitation error:
+ - An outstanding pending/accepted invitation already exists for that person or email.
+
+- RSVP not available:
+ - Event must be scheduled (`starts_at` present). Draft events do not accept RSVPs.
+
+- Calendar entry not created:
+ - Calendar entries are only created for `going` status; ensure the person has a primary calendar.
+
+## Related Files (Code Pointers)
+
+- Models:
+ - `app/models/better_together/invitation.rb`
+ - `app/models/better_together/event_invitation.rb`
+ - `app/models/better_together/event_attendance.rb`
+
+- Controllers:
+ - `app/controllers/better_together/events/invitations_controller.rb`
+ - `app/controllers/better_together/invitations_controller.rb`
+ - `app/controllers/better_together/events_controller.rb`
+
+- Policies:
+ - `app/policies/better_together/event_policy.rb`
+ - `app/policies/better_together/event_invitation_policy.rb`
+ - `app/policies/better_together/event_attendance_policy.rb`
+
+- Notifications & Mailers:
+ - `app/notifiers/better_together/event_invitation_notifier.rb`
+ - `app/mailers/better_together/event_invitations_mailer.rb`
+
+## Process Flow: Event Invitations
+
+```mermaid
+%% See separate Mermaid source file for editing: docs/diagrams/source/events_invitations_flow.mmd
+```
+
+**Diagram Files:**
+- 📊 Mermaid Source: ../diagrams/source/events_invitations_flow.mmd
+- 🖼️ PNG Export: ../diagrams/exports/png/events_invitations_flow.png
+- 🎯 SVG Export: ../diagrams/exports/svg/events_invitations_flow.svg
+
diff --git a/docs/developers/systems/events_system.md b/docs/developers/systems/events_system.md
index 91b10fffc..75a0b16fa 100644
--- a/docs/developers/systems/events_system.md
+++ b/docs/developers/systems/events_system.md
@@ -2,6 +2,8 @@
This document explains the Event model, how events are created and displayed, how visibility works, how calendars fit in, the comprehensive notification system for event reminders and updates, and the event hosting system.
+See also: [Event Invitations & Attendance](./event_invitations_and_attendance.md) for invitation tokens, delivery, and RSVP lifecycle details.
+
## Database Schema
The Events & Calendars domain consists of five primary tables plus standard shared tables (translations, ActionText, etc.). All Better Together tables are created via `create_bt_table`, which adds `id: :uuid`, `lock_version`, and `timestamps` automatically.
diff --git a/docs/diagrams/exports/png/events_access_via_invitation_token.png b/docs/diagrams/exports/png/events_access_via_invitation_token.png
new file mode 100644
index 000000000..b085e99ce
Binary files /dev/null and b/docs/diagrams/exports/png/events_access_via_invitation_token.png differ
diff --git a/docs/diagrams/exports/png/events_invitations_flow.png b/docs/diagrams/exports/png/events_invitations_flow.png
new file mode 100644
index 000000000..456071b64
Binary files /dev/null and b/docs/diagrams/exports/png/events_invitations_flow.png differ
diff --git a/docs/diagrams/exports/png/events_invitations_schema_erd.png b/docs/diagrams/exports/png/events_invitations_schema_erd.png
new file mode 100644
index 000000000..0b4601dfd
Binary files /dev/null and b/docs/diagrams/exports/png/events_invitations_schema_erd.png differ
diff --git a/docs/diagrams/exports/svg/events_access_via_invitation_token.svg b/docs/diagrams/exports/svg/events_access_via_invitation_token.svg
new file mode 100644
index 000000000..4ea159173
--- /dev/null
+++ b/docs/diagrams/exports/svg/events_access_via_invitation_token.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/diagrams/exports/svg/events_invitations_flow.svg b/docs/diagrams/exports/svg/events_invitations_flow.svg
new file mode 100644
index 000000000..e29fd0353
--- /dev/null
+++ b/docs/diagrams/exports/svg/events_invitations_flow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/diagrams/exports/svg/events_invitations_schema_erd.svg b/docs/diagrams/exports/svg/events_invitations_schema_erd.svg
new file mode 100644
index 000000000..d8f2d7cd6
--- /dev/null
+++ b/docs/diagrams/exports/svg/events_invitations_schema_erd.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/diagrams/source/events_access_via_invitation_token.mmd b/docs/diagrams/source/events_access_via_invitation_token.mmd
new file mode 100644
index 000000000..bbe5a3a90
--- /dev/null
+++ b/docs/diagrams/source/events_access_via_invitation_token.mmd
@@ -0,0 +1,19 @@
+sequenceDiagram
+ participant U as Unauthenticated User
+ participant EC as EventsController
+ participant P as EventPolicy/Scope
+ participant DB as DB
+
+ U->>EC: GET /:locale/bt/events/:id?invitation_token=TOKEN
+ Note right of EC: check_platform_privacy override
+ EC->>DB: Find Event by slug
+ EC->>DB: Find EventInvitation by token
+ alt invitation present & pending & not expired
+ EC->>EC: store token + locale in session
+ EC->>P: authorize show? (valid_invitation_token?)
+ P-->>EC: permitted
+ EC-->>U: render event show
+ else invalid/expired token
+ EC-->>U: redirect to sign-in
+ end
+
diff --git a/docs/diagrams/source/events_invitations_flow.mmd b/docs/diagrams/source/events_invitations_flow.mmd
new file mode 100644
index 000000000..d2ebdfddd
--- /dev/null
+++ b/docs/diagrams/source/events_invitations_flow.mmd
@@ -0,0 +1,46 @@
+flowchart TD
+ subgraph Organizer/Host
+ A1[Open Event Page] --> A2[Invitations Panel]
+ A2 --> A3{Invite Type}
+ A3 -->|Member| A4[Pick Person via available_people]
+ A3 -->|Email| A5[Enter Email + Locale]
+ A4 --> A6[POST /events/:id/invitations]
+ A5 --> A6
+ A6 --> A7{Valid?}
+ A7 -->|No| A8[Errors: duplicate/missing]
+ A7 -->|Yes| A9[Create EventInvitation pending]
+ A9 --> A10{Delivery}
+ A10 -->|Member| A11[Noticed + optional email]
+ A10 -->|Email| A12[Email via EventInvitationsMailer]
+ A11 --> A13[Update last_sent]
+ A12 --> A13
+ end
+
+ subgraph Invitee
+ B1[Receive Notification/Email] --> B2[Visit invitation URL]
+ B2 --> B3[GET /invitations/:token]
+ B3 --> B4{Authenticated?}
+ B4 -->|No| B5[Store token in session]
+ B5 --> B6[Redirect to sign-in/registration]
+ B6 --> B7[After auth: resume]
+ B4 -->|Yes| B8[Accept or Decline]
+ B7 --> B8
+ B8 -->|Decline| B9[Set status=declined]
+ B8 -->|Accept| C1[If invitee nil, bind to current person]
+ C1 --> C2[EventInvitation.accept!]
+ C2 --> C3[Ensure community membership]
+ C3 --> C4[Create/Update EventAttendance: going]
+ C4 --> C5[Create CalendarEntry]
+ end
+
+ subgraph Platform Privacy & Access
+ P1[Unauthenticated Event Show] --> P2{Platform public?}
+ P2 -->|Yes| P3[Proceed]
+ P2 -->|No| P4{Invitation token present/valid?}
+ P4 -->|No| P5[Redirect to sign-in]
+ P4 -->|Yes| P6[Allow access to specific event]
+ end
+
+ style A1 fill:#f3e5f5
+ style B1 fill:#e3f2fd
+ style P1 fill:#fff3e0
diff --git a/docs/diagrams/source/events_invitations_schema_erd.mmd b/docs/diagrams/source/events_invitations_schema_erd.mmd
new file mode 100644
index 000000000..872b64762
--- /dev/null
+++ b/docs/diagrams/source/events_invitations_schema_erd.mmd
@@ -0,0 +1,59 @@
+erDiagram
+ BETTER_TOGETHER_EVENTS ||--o{ BETTER_TOGETHER_EVENT_ATTENDANCES : has
+ BETTER_TOGETHER_EVENTS ||--o{ BETTER_TOGETHER_INVITATIONS : invites
+ BETTER_TOGETHER_PERSONS ||--o{ BETTER_TOGETHER_EVENT_ATTENDANCES : attends
+ BETTER_TOGETHER_PERSONS ||--o{ BETTER_TOGETHER_INVITATIONS : may_be_invitee
+
+ BETTER_TOGETHER_INVITATIONS {
+ uuid id PK
+ string type "STI: EventInvitation"
+ uuid invitable_id FK "Event ID"
+ string invitable_type
+ uuid inviter_id FK
+ string inviter_type
+ uuid invitee_id FK "optional"
+ string invitee_type "Person if present"
+ string invitee_email "optional"
+ string status "pending|accepted|declined (string enum)"
+ string token "secure unique"
+ string locale
+ timestamp valid_from
+ timestamp valid_until
+ timestamp accepted_at
+ timestamp last_sent
+ integer lock_version
+ timestamp created_at
+ timestamp updated_at
+ }
+
+ BETTER_TOGETHER_EVENT_ATTENDANCES {
+ uuid id PK
+ uuid event_id FK
+ uuid person_id FK
+ string status "interested|going (string enum)"
+ integer lock_version
+ timestamp created_at
+ timestamp updated_at
+ }
+
+ BETTER_TOGETHER_EVENTS {
+ uuid id PK
+ string type
+ uuid creator_id FK
+ string identifier
+ string privacy
+ timestamp starts_at
+ timestamp ends_at
+ integer duration_minutes
+ string registration_url
+ integer lock_version
+ timestamp created_at
+ timestamp updated_at
+ }
+
+ BETTER_TOGETHER_PERSONS {
+ uuid id PK
+ string name
+ string email "via user/contact detail"
+ }
+
diff --git a/docs/end_users/events_invitations_and_rsvp.md b/docs/end_users/events_invitations_and_rsvp.md
new file mode 100644
index 000000000..8d15d15d2
--- /dev/null
+++ b/docs/end_users/events_invitations_and_rsvp.md
@@ -0,0 +1,45 @@
+# Event Invitations and RSVP (End Users)
+
+This guide explains how to view and respond to event invitations, how RSVP works, and what to expect on private platforms.
+
+## Receiving Invitations
+
+- You may be invited to an event either as a member (in-app notification + optional email) or via email (external address).
+- Invitation links include a secure token that lets you review the event details and respond.
+- If you aren’t signed in, you’ll be redirected to sign in or register; your invitation is saved so you can continue after authentication.
+
+## Reviewing Invitations
+
+- Invitation review page: shows event name, description, and your invitation status.
+- Private platforms: your invitation link grants access to the specific event page even if the rest of the platform is private.
+- Language: the invitation is localized to the language selected by the inviter; your session will switch to that language on first visit.
+
+## Responding to Invitations
+
+- Accept: you’ll be added as “Going”. If needed, you’ll be added to the host community automatically.
+- Decline: marks the invitation declined; you can still view public details of the event if available.
+- After accepting, your RSVP will appear as “Going” and a calendar entry will be added to your personal calendar.
+
+## RSVP Controls on Event Pages
+
+- Interested: marks your interest without committing to attend.
+- Going: confirms attendance; creates a calendar entry.
+- Cancel: removes your attendance record and deletes the related calendar entry.
+- RSVP is available only after the event is scheduled (has a start time).
+
+## ICS Calendar Export
+
+- Event pages offer a “.ics” export (Add to Calendar) once scheduled.
+- If you RSVP “Going”, your personal calendar also gets a calendar entry automatically.
+
+## Privacy and Access
+
+- Invitation tokens are limited to the specific event; they don’t grant access beyond it.
+- If your token is expired or invalid, you’ll be asked to sign in or may see a not found page.
+
+## Troubleshooting
+
+- Link not working: check if the invitation has expired or was already accepted/declined.
+- Can’t RSVP: the event might still be a draft (no start time). Try again once it’s scheduled.
+- No calendar entry: only “Going” creates an entry; “Interested” does not. Make sure you have a primary calendar set up.
+- Wrong account: if the invitation is for a specific member account, log in with that account to accept.
diff --git a/docs/meta/documentation_assessment.md b/docs/meta/documentation_assessment.md
index ec5483c66..c92c19343 100644
--- a/docs/meta/documentation_assessment.md
+++ b/docs/meta/documentation_assessment.md
@@ -1,6 +1,6 @@
# Better Together Community Engine - Documentation Assessment & Progress Tracker
-**Last Updated:** August 22, 2025
+**Last Updated:** September 08, 2025
**Assessment Date:** August 21, 2025
**Schema Analysis Version:** 2025_08_21_121500
diff --git a/docs/platform_organizers/README.md b/docs/platform_organizers/README.md
index bec5b02dc..a84b29efe 100644
--- a/docs/platform_organizers/README.md
+++ b/docs/platform_organizers/README.md
@@ -5,6 +5,7 @@ This guide helps elected platform organizers manage platform-wide operations whi
## Platform Management
- [Host Management Guide](host_management.md) - Technical platform management
- [Host Dashboard Extensions](host_dashboard_extensions.md) - Advanced management features
+ - See: [Privacy & Invitation Tokens (Events)](host_management.md#privacy--invitation-tokens-events)
## User and Content Oversight
- [Accounts & Invitations](../developers/systems/accounts_and_invitations.md) - User lifecycle
diff --git a/docs/platform_organizers/host_management.md b/docs/platform_organizers/host_management.md
index 5a3a8609e..80cbd02e7 100644
--- a/docs/platform_organizers/host_management.md
+++ b/docs/platform_organizers/host_management.md
@@ -47,6 +47,19 @@ Under **Platforms**, edit these platform settings:
- **Privacy Settings**: Choose whether the platform is public, private (invite-only), or hidden from unregistered users.
- **Invitation Requirements**: Toggle whether users need an invitation code to register or if self-registration is open.
+### Privacy & Invitation Tokens (Events)
+
+- Private platforms: when privacy is set to private/invite-only, public browsing is limited. However, valid event invitation tokens allow invitees to access the specific event page without broad platform access.
+- How it works:
+ - Invitee opens an event invitation link (`/invitations/:token`) or an event URL with `?invitation_token=...`.
+ - The token is validated against a pending, non-expired `EventInvitation` for that event.
+ - On success, the token (and invitation locale) is stored in session, allowing access to that event page even if the platform is private.
+ - Invalid/expired tokens on private platforms redirect users to sign-in.
+- Registration mode: if “Requires Invitation” is enabled for platform registration, new users must provide a valid platform invitation code to register. Event invitations do not replace platform registration codes; they only grant access to view/respond to the specific event.
+- Security notes:
+ - Tokens are scoped to a single event and do not grant global access.
+ - Token validity windows can be set per invitation (valid_from/valid_until) and status changes remove access.
+
## Roles & Permissions
Roles and permissions are managed independently at the platform and community levels to provide scoped access control.
diff --git a/docs/table_of_contents.md b/docs/table_of_contents.md
index e91723c0c..7a9493a95 100644
--- a/docs/table_of_contents.md
+++ b/docs/table_of_contents.md
@@ -18,11 +18,13 @@ Welcome to the comprehensive documentation for the Better Together Community Eng
- [🗺️ User Guide](end_users/guide.md) - How to use the platform
- [👋 Welcome Guide](end_users/welcome.md) - Getting started for new users
- [🤝 Exchange Process](end_users/exchange_process.md) - How to participate in exchanges
+- [📅 Event Invitations & RSVP](end_users/events_invitations_and_rsvp.md) - Responding to invitations and managing attendance
#### 🌟 **Community Organizers** - [`community_organizers/`](community_organizers/)
*People managing and growing communities*
- [📝 README](community_organizers/README.md) - Community organizer resources
- [👥 Community Management](community_organizers/community_management.md) - Tools and best practices
+- [✉️ Event Invitations Management](community_organizers/event_invitations_management.md) - Invite members/emails and manage delivery
#### 🎛️ **Platform Organizers** - [`platform_organizers/`](platform_organizers/)
*Multi-tenant administrators and platform operators*
@@ -42,6 +44,7 @@ Welcome to the comprehensive documentation for the Better Together Community Eng
- [💬 Conversations Messaging System](developers/systems/conversations_messaging_system.md) - Real-time messaging
- [💬 Conversations README](developers/systems/README_conversations.md) - Messaging system overview
- [📅 Events System](developers/systems/events_system.md) - Event management
+- [✉️ Event Invitations & Attendance](developers/systems/event_invitations_and_attendance.md) - Invitations, tokens, RSVP
- [🗺️ Geography System](developers/systems/geography_system.md) - Location and mapping
- [🌍 I18n Mobility Localization System](developers/systems/i18n_mobility_localization_system.md) - Internationalization
- [📊 Metrics System](developers/systems/metrics_system.md) - Analytics and reporting
diff --git a/future_spec/models/better_together/invitation_spec.rb b/future_spec/models/better_together/invitation_spec.rb
index a7b39726e..28855b934 100644
--- a/future_spec/models/better_together/invitation_spec.rb
+++ b/future_spec/models/better_together/invitation_spec.rb
@@ -21,7 +21,7 @@ module BetterTogether
end
describe '#status' do
- it 'is a string enum' do # rubocop:todo RSpec/ExampleLength
+ it 'is a string enum' do
expect(subject).to( # rubocop:todo RSpec/NamedSubject
define_enum_for(:status).with_values(
accepted: 'accepted',
diff --git a/future_spec/requests/better_together/api/auth/confirmations_spec.rb b/future_spec/requests/better_together/api/auth/confirmations_spec.rb
index d64bcd861..594e42814 100644
--- a/future_spec/requests/better_together/api/auth/confirmations_spec.rb
+++ b/future_spec/requests/better_together/api/auth/confirmations_spec.rb
@@ -6,7 +6,7 @@
let(:user) { create(:user) }
let(:confirmation_token) { user.send(:generate_confirmation_token!) }
- context 'When requesting a new confirmation email' do # rubocop:todo RSpec/ContextWording
+ context 'When requesting a new confirmation email' do
let(:resend_confirmation_url) { better_together.user_confirmation_path }
before do
diff --git a/future_spec/requests/better_together/api/auth/passwords_spec.rb b/future_spec/requests/better_together/api/auth/passwords_spec.rb
index 6e43be64c..5b8f46a06 100644
--- a/future_spec/requests/better_together/api/auth/passwords_spec.rb
+++ b/future_spec/requests/better_together/api/auth/passwords_spec.rb
@@ -7,7 +7,7 @@
let(:login_url) { better_together.user_session_path }
let(:logout_url) { better_together.destroy_user_session_path }
- context 'When logging in' do # rubocop:todo RSpec/ContextWording
+ context 'When logging in' do
before do
login('manager@example.test', 'password12345')
end
@@ -21,7 +21,7 @@
end
end
- context 'When password is missing' do # rubocop:todo RSpec/ContextWording
+ context 'When password is missing' do
before do
post login_url, params: {
user: {
@@ -36,7 +36,7 @@
end
end
- context 'When logging out' do # rubocop:todo RSpec/ContextWording
+ context 'When logging out' do
it 'returns 200' do
delete logout_url
diff --git a/future_spec/requests/better_together/api/auth/registrations_spec.rb b/future_spec/requests/better_together/api/auth/registrations_spec.rb
index 46d557c63..6e78a9bb6 100644
--- a/future_spec/requests/better_together/api/auth/registrations_spec.rb
+++ b/future_spec/requests/better_together/api/auth/registrations_spec.rb
@@ -7,7 +7,7 @@
let(:existing_user) { create(:user, :confirmed) }
let(:signup_url) { better_together.user_registration_path }
- context 'When creating a new user' do # rubocop:todo RSpec/ContextWording
+ context 'When creating a new user' do
before do
post signup_url, params: {
user: {
@@ -31,7 +31,7 @@
end
end
- context 'When an email already exists' do # rubocop:todo RSpec/ContextWording
+ context 'When an email already exists' do
before do
post signup_url, params: {
user: {
diff --git a/future_spec/requests/better_together/api/auth/sessions_spec.rb b/future_spec/requests/better_together/api/auth/sessions_spec.rb
index 6e43be64c..5b8f46a06 100644
--- a/future_spec/requests/better_together/api/auth/sessions_spec.rb
+++ b/future_spec/requests/better_together/api/auth/sessions_spec.rb
@@ -7,7 +7,7 @@
let(:login_url) { better_together.user_session_path }
let(:logout_url) { better_together.destroy_user_session_path }
- context 'When logging in' do # rubocop:todo RSpec/ContextWording
+ context 'When logging in' do
before do
login('manager@example.test', 'password12345')
end
@@ -21,7 +21,7 @@
end
end
- context 'When password is missing' do # rubocop:todo RSpec/ContextWording
+ context 'When password is missing' do
before do
post login_url, params: {
user: {
@@ -36,7 +36,7 @@
end
end
- context 'When logging out' do # rubocop:todo RSpec/ContextWording
+ context 'When logging out' do
it 'returns 200' do
delete logout_url
diff --git a/spec/builders/better_together/builder_spec.rb b/spec/builders/better_together/builder_spec.rb
index 328c6302c..42bb19203 100644
--- a/spec/builders/better_together/builder_spec.rb
+++ b/spec/builders/better_together/builder_spec.rb
@@ -30,14 +30,14 @@ def cleared? = @cleared
describe '.build' do
it 'calls seed_data without clear when clear: false' do
- expect(subclass).to receive(:seed_data) # rubocop:todo RSpec/MessageSpies
- expect(subclass).not_to receive(:clear_existing) # rubocop:todo RSpec/MessageSpies
+ expect(subclass).to receive(:seed_data)
+ expect(subclass).not_to receive(:clear_existing)
subclass.build(clear: false)
end
it 'calls clear_existing and seed_data when clear: true' do
- expect(subclass).to receive(:clear_existing).ordered # rubocop:todo RSpec/MessageSpies
- expect(subclass).to receive(:seed_data).ordered # rubocop:todo RSpec/MessageSpies
+ expect(subclass).to receive(:clear_existing).ordered
+ expect(subclass).to receive(:seed_data).ordered
subclass.build(clear: true)
end
end
diff --git a/spec/builders/better_together/geography_builder_spec.rb b/spec/builders/better_together/geography_builder_spec.rb
index 0f25f49cb..96d491249 100644
--- a/spec/builders/better_together/geography_builder_spec.rb
+++ b/spec/builders/better_together/geography_builder_spec.rb
@@ -22,7 +22,7 @@
before { described_class.clear_existing }
# rubocop:todo RSpec/MultipleExpectations
- it 'creates continents from the predefined list' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates continents from the predefined list' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
continents_count = described_class.send(:continents).size
expect do
diff --git a/spec/controllers/better_together/person_blocks_controller_spec.rb b/spec/controllers/better_together/person_blocks_controller_spec.rb
index fdeaa864b..e72443ed4 100644
--- a/spec/controllers/better_together/person_blocks_controller_spec.rb
+++ b/spec/controllers/better_together/person_blocks_controller_spec.rb
@@ -14,10 +14,10 @@
let(:blocked_person) { create(:better_together_person) }
let(:another_person) { create(:better_together_person) }
- describe 'GET #search' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'GET #search' do
let!(:john_doe) { create(:better_together_person, name: 'John Doe', privacy: 'public') }
- it 'returns searchable people as JSON' do # rubocop:todo RSpec/ExampleLength
+ it 'returns searchable people as JSON' do
get :search, params: { locale: locale, q: 'John' }, format: :json
expect(response).to have_http_status(:success)
@@ -53,7 +53,7 @@
end
describe 'GET #index' do
- context 'when user has blocked people' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when user has blocked people' do
let!(:person_block) { create(:person_block, blocker: person, blocked: blocked_person) }
it 'returns http success' do
@@ -143,7 +143,7 @@
end
describe 'POST #create' do
- context 'with valid params' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'with valid params' do
let(:valid_params) { { locale: locale, person_block: { blocked_id: blocked_person.id } } }
# AC-2.1: I can block users from their profile page
@@ -166,7 +166,7 @@
# Test AJAX responses for interactive interface (FAILING - not implemented yet)
# rubocop:todo RSpec/NestedGroups
- context 'with AJAX request' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'with AJAX request' do # rubocop:todo RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
it 'responds with turbo_stream' do
post :create, params: valid_params, format: :turbo_stream
@@ -178,7 +178,7 @@
context 'with invalid params' do
# AC-2.8: I cannot block myself
# rubocop:todo RSpec/NestedGroups
- context 'when trying to block self' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'when trying to block self' do # rubocop:todo RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:invalid_params) { { locale: locale, person_block: { blocked_id: person.id } } }
@@ -196,7 +196,7 @@
# AC-2.7: I cannot block platform administrators
# rubocop:todo RSpec/NestedGroups
- context 'when trying to block platform administrator' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'when trying to block platform administrator' do # rubocop:todo RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:platform_admin) { create(:better_together_person) }
let(:invalid_params) { { locale: locale, person_block: { blocked_id: platform_admin.id } } }
@@ -230,7 +230,7 @@
end
# Test blocking by person ID (using the new select dropdown approach)
- context 'when blocking by person ID' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when blocking by person ID' do
let!(:target_person) { create(:better_together_person, identifier: 'targetuser') }
let(:valid_params) { { locale: locale, person_block: { blocked_id: target_person.id } } }
@@ -252,7 +252,7 @@
end
end
- describe 'DELETE #destroy' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'DELETE #destroy' do
let!(:person_block) { create(:person_block, blocker: person, blocked: blocked_person) }
# AC-2.4: I can unblock users from my block list
@@ -274,14 +274,14 @@
end
# Test AJAX responses for interactive interface (FAILING - not implemented yet)
- context 'with AJAX request' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'with AJAX request' do
it 'responds with turbo_stream' do
delete :destroy, params: { locale: locale, id: person_block.id }, format: :turbo_stream
expect(response.content_type).to include('text/vnd.turbo-stream.html')
end
end
- context 'when trying to destroy someone elses block' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when trying to destroy someone elses block' do
let(:other_persons_block) { create(:person_block, blocker: another_person, blocked: blocked_person) }
it 'renders not found (404)' do
@@ -290,7 +290,7 @@
end
end
- context 'when not authenticated' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when not authenticated' do
before { sign_out user }
it 'redirects to sign in' do
diff --git a/spec/controllers/concerns/notification_readable_spec.rb b/spec/controllers/concerns/notification_readable_spec.rb
index 833f140c5..12f11ac6e 100644
--- a/spec/controllers/concerns/notification_readable_spec.rb
+++ b/spec/controllers/concerns/notification_readable_spec.rb
@@ -13,7 +13,7 @@
describe '#mark_match_notifications_read_for' do
# rubocop:todo RSpec/MultipleExpectations
- it 'marks unread match notifications for the given record as read' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'marks unread match notifications for the given record as read' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
offer = create(:better_together_joatu_offer)
request = create(:better_together_joatu_request)
@@ -34,7 +34,7 @@
describe '#mark_notifications_read_for_record_id' do
# rubocop:todo RSpec/MultipleExpectations
- it 'marks unread notifications tied to the event record as read' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'marks unread notifications tied to the event record as read' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
conversation = create(:better_together_conversation)
create(:better_together_user, person: recipient)
diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb
index 245af03a2..b10cbc984 100644
--- a/spec/dummy/db/schema.rb
+++ b/spec/dummy/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_09_05_163813) do
+ActiveRecord::Schema[7.2].define(version: 2025_09_06_172911) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -730,7 +730,7 @@
t.datetime "last_sent"
t.datetime "accepted_at"
t.string "locale", limit: 5, default: "en", null: false
- t.string "token", limit: 24, null: false
+ t.string "token", limit: 64, null: false
t.string "invitable_type", null: false
t.uuid "invitable_id", null: false
t.string "inviter_type", null: false
@@ -1145,7 +1145,7 @@
t.uuid "platform_role_id"
t.string "status", limit: 20, null: false
t.string "locale", limit: 5, default: "en", null: false
- t.string "token", limit: 24, null: false
+ t.string "token", limit: 64, null: false
t.datetime "valid_from", null: false
t.datetime "valid_until"
t.datetime "last_sent"
diff --git a/spec/examples/automatic_configuration_examples_spec.rb b/spec/examples/automatic_configuration_examples_spec.rb
index f0a0aa678..f608a7322 100644
--- a/spec/examples/automatic_configuration_examples_spec.rb
+++ b/spec/examples/automatic_configuration_examples_spec.rb
@@ -26,7 +26,11 @@
context 'with authenticated user', :as_user do
it 'automatically authenticates as regular user' do
# This would test user-accessible endpoints
- expect(response).to be_nil # Just showing the setup works
+ # Test that we can access a user-accessible endpoint
+ get better_together.conversations_path(locale:)
+ expect(response).to have_http_status(:ok)
+ # Verify session contains user information
+ expect(session['warden.user.user.key']).to be_present
end
end
@@ -34,7 +38,11 @@
context 'without authentication', :no_auth do
it 'remains unauthenticated' do
# This would test public endpoints
- expect(response).to be_nil # Just showing the setup works
+ # Test a simple endpoint that should redirect to login when not authenticated
+ # Use a path that exists regardless of host setup
+ get better_together.new_user_session_path(locale:)
+ expect(response).to have_http_status(:ok) # Login page should be accessible
+ expect(response.body).to include('Sign In') # Should show login form
end
end
@@ -42,7 +50,8 @@
context 'without host platform setup', :skip_host_setup do
it 'skips automatic host platform configuration' do
# This would test the host setup wizard or similar flows
- expect(response).to be_nil # Just showing the setup works
+ # Just verify the metadata worked
+ expect(RSpec.current_example.metadata[:skip_host_setup]).to be true
end
end
end
diff --git a/spec/factories/better_together/event_invitations.rb b/spec/factories/better_together/event_invitations.rb
index c4638f410..863be80b2 100644
--- a/spec/factories/better_together/event_invitations.rb
+++ b/spec/factories/better_together/event_invitations.rb
@@ -1,42 +1,48 @@
# frozen_string_literal: true
-FactoryBot.define do
- factory 'better_together/event_invitation',
- class: 'BetterTogether::EventInvitation',
- aliases: %i[better_together_event_invitation event_invitation] do
- id { SecureRandom.uuid }
- lock_version { 0 }
- invitee_email { Faker::Internet.email }
- status { 'pending' }
- locale { I18n.available_locales.sample.to_s }
- valid_from { Time.zone.now }
- valid_until { valid_from + 7.days } # Optional expiry
-
- # Required associations for Invitation base class
- association :invitable, factory: :better_together_event
- association :inviter, factory: :better_together_person
-
- # Optional associations - invitee is nil for email-based invitations
- invitee { nil }
- role { nil } # Optional role assignment
-
- # The token should be auto-generated by has_secure_token in the model
-
- trait :expired do
- valid_until { 1.day.ago }
- end
-
- trait :accepted do
- status { 'accepted' }
- accepted_at { Time.current }
- end
-
- trait :declined do
- status { 'declined' }
- end
-
- trait :with_invitee do
- association :invitee, factory: :better_together_person
+unless FactoryBot.factories.registered?(:'better_together/event_invitation')
+ FactoryBot.define do
+ factory 'better_together/event_invitation',
+ class: 'BetterTogether::EventInvitation',
+ aliases: %i[better_together_event_invitation event_invitation] do
+ id { SecureRandom.uuid }
+ lock_version { 0 }
+ invitee_email { Faker::Internet.email }
+ status { 'pending' }
+ locale { I18n.available_locales.sample.to_s }
+ valid_from { Time.zone.now }
+ valid_until { valid_from + 7.days } # Optional expiry
+
+ # Required associations for Invitation base class
+ association :invitable, factory: :better_together_event
+ association :inviter, factory: :better_together_person
+
+ # Optional associations - invitee is nil for email-based invitations
+ invitee { nil }
+ role { nil } # Optional role assignment
+
+ # The token should be auto-generated by has_secure_token in the model
+
+ trait :expired do
+ valid_until { 1.day.ago }
+ end
+
+ trait :accepted do
+ status { 'accepted' }
+ accepted_at { Time.current }
+ end
+
+ trait :declined do
+ status { 'declined' }
+ end
+
+ trait :with_invitee do
+ association :invitee, factory: :better_together_person
+ after(:build) do |invitation|
+ # DB requires invitee_email to be non-null; mirror the invitee's email when present
+ invitation.invitee_email = invitation.invitee.email if invitation.invitee.respond_to?(:email)
+ end
+ end
end
end
end
diff --git a/spec/factories/better_together/events.rb b/spec/factories/better_together/events.rb
index bb09c607f..fc7036d0d 100644
--- a/spec/factories/better_together/events.rb
+++ b/spec/factories/better_together/events.rb
@@ -4,9 +4,9 @@
factory 'better_together/event',
class: 'BetterTogether::Event',
aliases: %i[better_together_event event] do
- id { Faker::Internet.uuid }
+ # Remove manual ID setting - let Rails handle this
identifier { Faker::Internet.unique.uuid }
- name { Faker::Lorem.words(number: 3).join(' ').titleize }
+ name { Faker::Lorem.unique.words(number: 3).join(' ').titleize }
description { Faker::Lorem.paragraphs(number: 2).join("\n\n") }
starts_at { 1.week.from_now }
ends_at { 1.week.from_now + 2.hours }
diff --git a/spec/features/agreements/registration_consent_spec.rb b/spec/features/agreements/registration_consent_spec.rb
index 28b1c43f7..ea6efe3dc 100644
--- a/spec/features/agreements/registration_consent_spec.rb
+++ b/spec/features/agreements/registration_consent_spec.rb
@@ -3,14 +3,13 @@
require 'rails_helper'
RSpec.describe 'User registration agreements', :as_platform_manager do
- # rubocop:todo RSpec/LetSetup
let!(:privacy_agreement) { BetterTogether::Agreement.find_by!(identifier: 'privacy_policy') }
# rubocop:enable RSpec/LetSetup
# rubocop:todo RSpec/LetSetup
let!(:tos_agreement) { BetterTogether::Agreement.find_by!(identifier: 'terms_of_service') }
# rubocop:enable RSpec/LetSetup
- it 'requires accepting agreements during sign up' do # rubocop:todo RSpec/ExampleLength
+ it 'requires accepting agreements during sign up' do
visit new_user_registration_path(locale: I18n.default_locale)
fill_in 'user[email]', with: 'test@example.test'
@@ -26,7 +25,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates agreement participants when agreements are accepted' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates agreement participants when agreements are accepted' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
visit new_user_registration_path(locale: I18n.default_locale)
diff --git a/spec/features/checklist_create_appends_spec.rb b/spec/features/checklist_create_appends_spec.rb
index 6a8a9ee5d..faa30845b 100644
--- a/spec/features/checklist_create_appends_spec.rb
+++ b/spec/features/checklist_create_appends_spec.rb
@@ -12,7 +12,7 @@
login_as(manager, scope: :user)
end
- it 'creates a new checklist item and it appears at the bottom after refresh' do # rubocop:todo RSpec/ExampleLength
+ it 'creates a new checklist item and it appears at the bottom after refresh' do
# rubocop:enable RSpec/MultipleExpectations
checklist = create(:better_together_checklist, title: 'Append Test Checklist')
diff --git a/spec/features/checklist_person_completion_spec.rb b/spec/features/checklist_person_completion_spec.rb
index c62d431a0..5da732dc6 100644
--- a/spec/features/checklist_person_completion_spec.rb
+++ b/spec/features/checklist_person_completion_spec.rb
@@ -6,7 +6,7 @@
include Devise::Test::IntegrationHelpers
let(:user) { create(:user) }
- let!(:person) { create(:better_together_person, user: user) } # rubocop:todo RSpec/LetSetup
+ let!(:person) { create(:better_together_person, user: user) }
before do
find_or_create_test_user('user@example.test', 'password12345', :user)
@@ -15,7 +15,7 @@
# rubocop:todo RSpec/PendingWithoutReason
# rubocop:todo RSpec/MultipleExpectations
- xit 'allows a person to complete all items and shows completion message' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations, RSpec/PendingWithoutReason
+ xit 'allows a person to complete all items and shows completion message' do # rubocop:todo RSpec/MultipleExpectations, RSpec/PendingWithoutReason
# rubocop:enable RSpec/MultipleExpectations
# rubocop:enable RSpec/PendingWithoutReason
checklist = create(:better_together_checklist, privacy: 'public')
diff --git a/spec/features/checklist_reorder_system_spec.rb b/spec/features/checklist_reorder_system_spec.rb
index 33eff40de..2b24c2aa0 100644
--- a/spec/features/checklist_reorder_system_spec.rb
+++ b/spec/features/checklist_reorder_system_spec.rb
@@ -15,7 +15,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'allows reordering items via move buttons (server-driven)' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'allows reordering items via move buttons (server-driven)' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
checklist = create(:better_together_checklist, title: 'Test Checklist')
diff --git a/spec/features/conversations/create_spec.rb b/spec/features/conversations/create_spec.rb
index bece0038a..44130a09b 100644
--- a/spec/features/conversations/create_spec.rb
+++ b/spec/features/conversations/create_spec.rb
@@ -17,14 +17,13 @@
expect(BetterTogether::Conversation.count).to eq(1)
end
- context 'as a normal user' do # rubocop:todo RSpec/ContextWording
+ context 'as a normal user' do
before do
sign_in_user(user.email, user.password)
end
let(:user2) { create(:better_together_user) }
- # rubocop:todo RSpec/ExampleLength
scenario 'can create a conversation with a public person who opted into messages', :js do
target = create(:better_together_user, :confirmed)
# Ensure target is public and opted-in to receive messages from members
diff --git a/spec/features/conversations_client_validation_spec.rb b/spec/features/conversations_client_validation_spec.rb
index 99629cdc9..7dc072b9d 100644
--- a/spec/features/conversations_client_validation_spec.rb
+++ b/spec/features/conversations_client_validation_spec.rb
@@ -14,7 +14,6 @@
login_as(user, scope: :user)
end
- # rubocop:todo RSpec/ExampleLength
it 'prevents submission and shows client-side validation when first message is empty' do
# rubocop:enable RSpec/MultipleExpectations
visit better_together.new_conversation_path(locale: I18n.default_locale,
diff --git a/spec/features/devise/registration_spec.rb b/spec/features/devise/registration_spec.rb
index c5f4c7d63..2c989c407 100644
--- a/spec/features/devise/registration_spec.rb
+++ b/spec/features/devise/registration_spec.rb
@@ -3,7 +3,7 @@
require 'rails_helper'
# rubocop:disable Metrics/BlockLength
-RSpec.feature 'User Registration' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+RSpec.feature 'User Registration' do
# Ensure you have a valid user created; using FactoryBot here
let!(:host_setup_wizard) do
BetterTogether::Wizard.find_or_create_by(identifier: 'host_setup')
@@ -25,19 +25,19 @@
a.title = 'Code of Conduct'
end
end
- let!(:privacy_term) do # rubocop:todo RSpec/LetSetup
+ let!(:privacy_term) do
create(:agreement_term, agreement: privacy_agreement, summary: 'We respect your privacy.', position: 1)
end
- let!(:tos_term) do # rubocop:todo RSpec/LetSetup
+ let!(:tos_term) do
create(:agreement_term, agreement: tos_agreement, summary: 'Be excellent to each other.', position: 1)
end
- let!(:code_of_conduct_term) do # rubocop:todo RSpec/LetSetup
+ let!(:code_of_conduct_term) do
create(:agreement_term, agreement: code_of_conduct_agreement, summary: 'Treat everyone with respect and kindness.',
position: 1)
end
# rubocop:todo RSpec/MultipleExpectations
- scenario 'User registers successfully' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ scenario 'User registers successfully' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
host_setup_wizard.mark_completed
# byebug
diff --git a/spec/features/events/location_selector_spec.rb b/spec/features/events/location_selector_spec.rb
index 772325a86..8acd761a7 100644
--- a/spec/features/events/location_selector_spec.rb
+++ b/spec/features/events/location_selector_spec.rb
@@ -3,7 +3,6 @@
require 'rails_helper'
RSpec.feature 'Event location selector', :as_platform_manager, :js do
- # rubocop:todo RSpec/ExampleLength
scenario 'shows inline new address and building blocks', skip: 'temporarily disabled (location selector flakiness)' do
# rubocop:enable RSpec/MultipleExpectations
visit better_together.new_event_path(locale: I18n.default_locale)
diff --git a/spec/features/joatu/agreement_rejection_spec.rb b/spec/features/joatu/agreement_rejection_spec.rb
index 99c66a94c..225071ae7 100644
--- a/spec/features/joatu/agreement_rejection_spec.rb
+++ b/spec/features/joatu/agreement_rejection_spec.rb
@@ -4,7 +4,7 @@
RSpec.feature 'Joatu agreement rejection' do
# rubocop:todo RSpec/MultipleExpectations
- scenario 'rejects an agreement without closing offer or request' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ scenario 'rejects an agreement without closing offer or request' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
requestor = create(:better_together_person)
offeror = create(:better_together_person)
diff --git a/spec/features/joatu/matchmaking_spec.rb b/spec/features/joatu/matchmaking_spec.rb
index 6ecf70ab2..c046d77d7 100644
--- a/spec/features/joatu/matchmaking_spec.rb
+++ b/spec/features/joatu/matchmaking_spec.rb
@@ -4,7 +4,7 @@
RSpec.feature 'Joatu matchmaking' do
# rubocop:todo RSpec/MultipleExpectations
- scenario 'matches offers with requests and finalizes agreement' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ scenario 'matches offers with requests and finalizes agreement' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
requestor = create(:better_together_person)
offeror = create(:better_together_person)
diff --git a/spec/features/joatu/offers_requests_forms_spec.rb b/spec/features/joatu/offers_requests_forms_spec.rb
index 6f684abb7..875fa40cf 100644
--- a/spec/features/joatu/offers_requests_forms_spec.rb
+++ b/spec/features/joatu/offers_requests_forms_spec.rb
@@ -20,7 +20,7 @@
expect(page).to have_content('Bike repair')
end
- scenario 'creating a request' do # rubocop:todo RSpec/ExampleLength
+ scenario 'creating a request' do
visit new_joatu_request_path(locale: I18n.default_locale)
fill_in name: 'joatu_request[name_en]', with: 'Need a ladder'
# Populate the underlying ActionText hidden input for current locale
diff --git a/spec/features/joatu/respond_with_offer_spec.rb b/spec/features/joatu/respond_with_offer_spec.rb
index 533f99102..d69c4afe8 100644
--- a/spec/features/joatu/respond_with_offer_spec.rb
+++ b/spec/features/joatu/respond_with_offer_spec.rb
@@ -8,7 +8,8 @@
let(:owner_user) { create(:user, :confirmed) }
let(:responder_user) { create(:user, :confirmed) }
let(:request_resource) { create(:better_together_joatu_request, creator: owner_user.person) }
- scenario 'shows respond with offer button and redirects with source params' do # rubocop:todo RSpec/ExampleLength
+
+ scenario 'shows respond with offer button and redirects with source params' do
# rubocop:enable RSpec/MultipleExpectations
visit better_together.joatu_request_path(request_resource, locale: I18n.locale)
diff --git a/spec/features/notifications/unread_badge_spec.rb b/spec/features/notifications/unread_badge_spec.rb
index 1c6266256..2fc045627 100644
--- a/spec/features/notifications/unread_badge_spec.rb
+++ b/spec/features/notifications/unread_badge_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe 'notification badge' do
context 'with platform manager role' do
- it 'updates badge and title based on unread count', :js do # rubocop:todo RSpec/ExampleLength
+ it 'updates badge and title based on unread count', :js do
visit conversations_path(locale: I18n.default_locale)
original_title = page.title
diff --git a/spec/features/setup_wizard_spec.rb b/spec/features/setup_wizard_spec.rb
index 53e846829..6d1dae0a5 100644
--- a/spec/features/setup_wizard_spec.rb
+++ b/spec/features/setup_wizard_spec.rb
@@ -3,7 +3,6 @@
require 'rails_helper'
RSpec.feature 'Setup Wizard Flow', :js, skip: 'flaky/setup_wizard - disabled while debugging suite' do
- # rubocop:todo RSpec/ExampleLength
scenario 'redirects from root and completes the first wizard step using platform attributes' do
# rubocop:enable RSpec/MultipleExpectations
# Build a platform instance (using FactoryBot) with test data
diff --git a/spec/features/translatable_attachments_integration_spec.rb b/spec/features/translatable_attachments_integration_spec.rb
index 78ae57f3b..9ed7ae33d 100644
--- a/spec/features/translatable_attachments_integration_spec.rb
+++ b/spec/features/translatable_attachments_integration_spec.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# rubocop:disable RSpec/ExampleLength
-
require 'rails_helper'
RSpec.describe 'Translatable Attachments integration', type: :model do
diff --git a/spec/jobs/better_together/application_job_spec.rb b/spec/jobs/better_together/application_job_spec.rb
index 3b9cc5acf..9953735bd 100644
--- a/spec/jobs/better_together/application_job_spec.rb
+++ b/spec/jobs/better_together/application_job_spec.rb
@@ -50,7 +50,7 @@ def self.call(*args); end
end
it 'executes perform' do
- expect(MyService).to receive(:call).with(123) # rubocop:todo RSpec/MessageSpies
+ expect(MyService).to receive(:call).with(123)
perform_enqueued_jobs { job }
end
end
diff --git a/spec/lib/mobility_attachments_backend_spec.rb b/spec/lib/mobility_attachments_backend_spec.rb
index 2cc42474a..0fb9aa1e6 100644
--- a/spec/lib/mobility_attachments_backend_spec.rb
+++ b/spec/lib/mobility_attachments_backend_spec.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# rubocop:disable RSpec/ExampleLength
-
require 'rails_helper'
RSpec.describe 'Mobility Attachments Backend (prototype)', type: :model do
diff --git a/spec/mailers/better_together/conversation_mailer_spec.rb b/spec/mailers/better_together/conversation_mailer_spec.rb
index eaf06eb49..ee6b6aaab 100644
--- a/spec/mailers/better_together/conversation_mailer_spec.rb
+++ b/spec/mailers/better_together/conversation_mailer_spec.rb
@@ -4,8 +4,8 @@
module BetterTogether
RSpec.describe ConversationMailer do
- describe 'new_message_notification' do # rubocop:todo RSpec/MultipleMemoizedHelpers
- let!(:host_platform) { create(:platform, :host) } # rubocop:todo RSpec/LetSetup
+ describe 'new_message_notification' do
+ let!(:host_platform) { create(:platform, :host) }
let(:sender) { create(:user) }
let(:recipient) { create(:user) }
let(:conversation) { create(:conversation, creator: sender.person) }
diff --git a/spec/mailers/better_together/joatu_mailer_spec.rb b/spec/mailers/better_together/joatu_mailer_spec.rb
index 92e71a2b8..cda24d09d 100644
--- a/spec/mailers/better_together/joatu_mailer_spec.rb
+++ b/spec/mailers/better_together/joatu_mailer_spec.rb
@@ -5,8 +5,8 @@
# rubocop:disable Metrics/BlockLength
module BetterTogether
RSpec.describe JoatuMailer do
- describe 'new_match' do # rubocop:todo RSpec/MultipleMemoizedHelpers
- let!(:host_platform) { create(:platform, :host) } # rubocop:todo RSpec/LetSetup
+ describe 'new_match' do
+ let!(:host_platform) { create(:platform, :host) }
let(:recipient_user) { create(:user) }
let(:offer_user) { create(:user) }
let(:request_user) { create(:user) }
@@ -21,8 +21,8 @@ module BetterTogether
end
end
- describe 'agreement_created' do # rubocop:todo RSpec/MultipleMemoizedHelpers
- let!(:host_platform) { create(:platform, :host) } # rubocop:todo RSpec/LetSetup
+ describe 'agreement_created' do
+ let!(:host_platform) { create(:platform, :host) }
let(:offer_user) { create(:user) }
let(:request_user) { create(:user) }
let(:offer) { create(:joatu_offer, creator: offer_user.person) }
@@ -46,7 +46,7 @@ module BetterTogether
end
# rubocop:todo RSpec/MultipleExpectations
- it 'sends the email' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'sends the email' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
expect { mail.deliver_now }
.to change { ActionMailer::Base.deliveries.count }.by(1)
diff --git a/spec/models/better_together/authorship_spec.rb b/spec/models/better_together/authorship_spec.rb
index 879efeb22..73f08dee7 100644
--- a/spec/models/better_together/authorship_spec.rb
+++ b/spec/models/better_together/authorship_spec.rb
@@ -23,7 +23,7 @@ module BetterTogether
end.not_to(change(Noticed::Notification, :count))
end
- it 'notifies when current_person adds someone else' do # rubocop:todo RSpec/ExampleLength
+ it 'notifies when current_person adds someone else' do
other = create(:person)
expect do
described_class.with_creator(person) do
@@ -47,7 +47,7 @@ module BetterTogether
end
end
- it 'does not notify when current_person removes themselves' do # rubocop:todo RSpec/ExampleLength
+ it 'does not notify when current_person removes themselves' do
prev = defined?(::Current) && ::Current.respond_to?(:person) ? ::Current.person : nil
::Current.person = person if defined?(::Current)
@@ -60,7 +60,7 @@ module BetterTogether
::Current.person = prev if defined?(::Current)
end
- it 'notifies when someone else removes the author' do # rubocop:todo RSpec/ExampleLength
+ it 'notifies when someone else removes the author' do
other = create(:person)
prev = defined?(::Current) && ::Current.respond_to?(:person) ? ::Current.person : nil
::Current.person = other if defined?(::Current)
diff --git a/spec/models/better_together/checklist_item_position_spec.rb b/spec/models/better_together/checklist_item_position_spec.rb
index 670d97a01..5bf75c51e 100644
--- a/spec/models/better_together/checklist_item_position_spec.rb
+++ b/spec/models/better_together/checklist_item_position_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe BetterTogether::ChecklistItem do
# rubocop:todo RSpec/MultipleExpectations
- it 'assigns incremental position scoped by checklist and parent' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'assigns incremental position scoped by checklist and parent' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
checklist = create(:better_together_checklist)
diff --git a/spec/models/better_together/conversation_spec.rb b/spec/models/better_together/conversation_spec.rb
index d7e160eac..c93f88bc9 100644
--- a/spec/models/better_together/conversation_spec.rb
+++ b/spec/models/better_together/conversation_spec.rb
@@ -15,7 +15,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'retries once on ActiveRecord::StaleObjectError and succeeds' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'retries once on ActiveRecord::StaleObjectError and succeeds' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# Simulate the association raising once, then succeeding on retry.
proxy = conversation.participants
diff --git a/spec/models/better_together/event_invitation_combined_spec.rb b/spec/models/better_together/event_invitation_combined_spec.rb
new file mode 100644
index 000000000..e6e0706ec
--- /dev/null
+++ b/spec/models/better_together/event_invitation_combined_spec.rb
@@ -0,0 +1,378 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+ let(:invitee_person) { create(:better_together_person, locale: 'es') }
+ let(:community) { configure_host_platform&.community }
+ let(:community_role) { BetterTogether::Role.find_by(identifier: 'community_member') }
+
+ describe 'enhanced validations' do
+ context 'invitation uniqueness' do
+ it 'prevents duplicate person invitations for the same event' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee]).to include('has already been invited to this event')
+ end
+
+ it 'prevents duplicate email invitations for the same event' do
+ email = 'test@example.com'
+
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee_email]).to include('has already been invited to this event')
+ end
+
+ it 'allows duplicate invitations if previous one was declined' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'declined')
+
+ new_invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(new_invitation).to be_valid
+ end
+
+ it 'allows same person to be invited to different events' do
+ other_event = create(:better_together_event)
+
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ other_invitation = build(:better_together_event_invitation,
+ invitable: other_event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(other_invitation).to be_valid
+ end
+ end
+
+ context 'invitee presence validation' do
+ it 'requires either invitee or invitee_email' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:base]).to include('Either invitee or invitee_email must be present')
+ end
+
+ it 'accepts invitation with invitee only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+
+ expect(invitation).to be_valid
+ end
+
+ it 'accepts invitation with invitee_email only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+
+ expect(invitation).to be_valid
+ end
+ end
+ end
+
+ describe 'invitation type helpers' do
+ context 'person invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+ end
+
+ it 'correctly identifies as person invitation' do
+ expect(invitation.invitation_type).to eq(:person)
+ expect(invitation.for_existing_user?).to be true
+ expect(invitation.for_email?).to be false
+ end
+ end
+
+ context 'email invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+ end
+
+ it 'correctly identifies as email invitation' do
+ expect(invitation.invitation_type).to eq(:email)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be true
+ end
+ end
+
+ context 'unknown invitation type' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+ end
+
+ it 'identifies as unknown when both are nil' do
+ expect(invitation.invitation_type).to eq(:unknown)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be false
+ end
+ end
+ end
+
+ describe 'enhanced scopes' do
+ let!(:person_invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ let!(:email_invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'email@example.com')
+ end
+
+ describe '.for_existing_users' do
+ it 'returns only invitations with an invitee person' do
+ results = described_class.for_existing_users
+
+ expect(results).to include(person_invitation)
+ expect(results).not_to include(email_invitation)
+ end
+ end
+
+ describe '.for_email_addresses' do
+ it 'returns only invitations with an email address but no invitee' do
+ results = described_class.for_email_addresses
+
+ expect(results).to include(email_invitation)
+ expect(results).not_to include(person_invitation)
+ end
+ end
+ end
+
+ describe 'enhanced URL generation' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'es',
+ token: 'test-token-123')
+ end
+
+ it 'generates URLs that link directly to the event with invitation token' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ expect(url).to include(event.slug)
+ expect(uri.query).to include('invitation_token=test-token-123')
+
+ if uri.query&.include?('locale=')
+ expect(uri.query).to include('locale=es')
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(url).not_to include('/invitations/')
+ end
+
+ it 'includes proper locale in generated URLs' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ params = uri.query ? CGI.parse(uri.query) : {}
+
+ if params['locale'].present?
+ expect(params['locale']).to eq(['es'])
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(params['invitation_token']).to eq(['test-token-123'])
+ end
+ end
+
+ describe 'enhanced acceptance flow' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+ end
+
+ describe '#after_accept!' do
+ it 'creates event attendance with going status' do
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitee_person)
+ expect(attendance.event).to eq(event)
+ expect(attendance.status).to eq('going')
+ end
+
+ it 'does not create duplicate community memberships' do
+ community.person_community_memberships.create!(
+ member: invitee_person,
+ role: community_role
+ )
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.not_to change(community.person_community_memberships, :count)
+ end
+
+ it 'handles missing community gracefully' do
+ allow(event.creator).to receive(:primary_community).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitee_person)
+ end
+
+ it 'handles missing event creator gracefully' do
+ allow(event).to receive(:creator).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+ end
+ end
+
+ describe '#accept!' do
+ it 'sets status to accepted and calls after_accept!' do
+ expect(invitation).to receive(:after_accept!).with(invitee_person: invitee_person)
+
+ invitation.accept!(invitee_person: invitee_person)
+
+ expect(invitation.status).to eq('accepted')
+ expect(invitation).to be_persisted
+ end
+ end
+
+ describe '#decline!' do
+ it 'sets status to declined' do
+ invitation.decline!
+
+ expect(invitation.status).to eq('declined')
+ expect(invitation).to be_persisted
+ end
+ end
+ end
+
+ describe 'token generation' do
+ it 'automatically generates a token before validation' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ token: nil)
+
+ expect(invitation.token).to be_nil
+ invitation.valid?
+ expect(invitation.token).to be_present
+ end
+
+ it 'does not overwrite existing tokens' do
+ original_token = 'existing-token'
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ token: original_token)
+
+ invitation.valid?
+ expect(invitation.token).to eq(original_token)
+ end
+ end
+
+ describe 'locale handling' do
+ it 'validates locale is in available locales' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'invalid')
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:locale]).to include('is not included in the list')
+ end
+
+ it 'accepts valid locales' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'es')
+
+ expect(invitation).to be_valid
+ end
+
+ it 'requires locale to be present' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: nil)
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:locale]).to include("can't be blank")
+ end
+ end
+end
diff --git a/spec/models/better_together/event_invitation_enhanced_functionality_spec.rb b/spec/models/better_together/event_invitation_enhanced_functionality_spec.rb
new file mode 100644
index 000000000..3a8cce040
--- /dev/null
+++ b/spec/models/better_together/event_invitation_enhanced_functionality_spec.rb
@@ -0,0 +1,390 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+ let(:invitee_person) { create(:better_together_person, locale: 'es') }
+ let(:community) { configure_host_platform&.community }
+ let(:community_role) { BetterTogether::Role.find_by(identifier: 'community_member') }
+
+ describe 'enhanced validations' do
+ context 'invitation uniqueness' do
+ it 'prevents duplicate person invitations for the same event' do
+ # Create first invitation
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ # Try to create duplicate
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee]).to include('has already been invited to this event')
+ end
+
+ it 'prevents duplicate email invitations for the same event' do
+ email = 'test@example.com'
+
+ # Create first invitation
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ # Try to create duplicate
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee_email]).to include('has already been invited to this event')
+ end
+
+ it 'allows duplicate invitations if previous one was declined' do
+ # Create declined invitation
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'declined')
+
+ # Should allow new invitation
+ new_invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(new_invitation).to be_valid
+ end
+
+ it 'allows same person to be invited to different events' do
+ other_event = create(:better_together_event)
+
+ # Create invitation for first event
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ # Should allow invitation to different event
+ other_invitation = build(:better_together_event_invitation,
+ invitable: other_event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(other_invitation).to be_valid
+ end
+ end
+
+ context 'invitee presence validation' do
+ it 'requires either invitee or invitee_email' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:base]).to include('Either invitee or invitee_email must be present')
+ end
+
+ it 'accepts invitation with invitee only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+
+ expect(invitation).to be_valid
+ end
+
+ it 'accepts invitation with invitee_email only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+
+ expect(invitation).to be_valid
+ end
+ end
+ end
+
+ describe 'invitation type helpers' do
+ context 'person invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+ end
+
+ it 'correctly identifies as person invitation' do
+ expect(invitation.invitation_type).to eq(:person)
+ expect(invitation.for_existing_user?).to be true
+ expect(invitation.for_email?).to be false
+ end
+ end
+
+ context 'email invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+ end
+
+ it 'correctly identifies as email invitation' do
+ expect(invitation.invitation_type).to eq(:email)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be true
+ end
+ end
+
+ context 'unknown invitation type' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+ end
+
+ it 'identifies as unknown when both are nil' do
+ expect(invitation.invitation_type).to eq(:unknown)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be false
+ end
+ end
+ end
+
+ describe 'enhanced scopes' do
+ let!(:person_invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ let!(:email_invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'email@example.com')
+ end
+
+ describe '.for_existing_users' do
+ it 'returns only invitations with an invitee person' do
+ results = described_class.for_existing_users
+
+ expect(results).to include(person_invitation)
+ expect(results).not_to include(email_invitation)
+ end
+ end
+
+ describe '.for_email_addresses' do
+ it 'returns only invitations with an email address but no invitee' do
+ results = described_class.for_email_addresses
+
+ expect(results).to include(email_invitation)
+ expect(results).not_to include(person_invitation)
+ end
+ end
+ end
+
+ describe 'enhanced URL generation' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'es',
+ token: 'test-token-123')
+ end
+
+ it 'generates URLs that link directly to the event with invitation token' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ expect(url).to include(event.slug)
+ expect(uri.query).to include('invitation_token=test-token-123')
+
+ # Locale may be embedded in the path (e.g. /es/events/...), accept either
+ if uri.query&.include?('locale=')
+ expect(uri.query).to include('locale=es')
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(url).not_to include('/invitations/') # Should not use generic invitation path
+ end
+
+ it 'includes proper locale in generated URLs' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ params = uri.query ? CGI.parse(uri.query) : {}
+
+ # If locale present in query, assert it; otherwise ensure path contains locale segment
+ if params['locale'].present?
+ expect(params['locale']).to eq(['es'])
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(params['invitation_token']).to eq(['test-token-123'])
+ end
+ end
+
+ describe 'enhanced acceptance flow' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+ end
+
+ describe '#after_accept!' do
+ it 'creates event attendance with going status' do
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitee_person)
+ expect(attendance.event).to eq(event)
+ expect(attendance.status).to eq('going')
+ end
+
+ it 'does not create duplicate community memberships' do
+ # Create existing membership
+ community.person_community_memberships.create!(
+ member: invitee_person,
+ role: community_role
+ )
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.not_to change(community.person_community_memberships, :count)
+ end
+
+ it 'handles missing community gracefully' do
+ allow(event.creator).to receive(:primary_community).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ # Should still create attendance even without community
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitee_person)
+ end
+
+ it 'handles missing event creator gracefully' do
+ allow(event).to receive(:creator).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitee_person)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+ end
+ end
+
+ describe '#accept!' do
+ it 'sets status to accepted and calls after_accept!' do
+ expect(invitation).to receive(:after_accept!).with(invitee_person: invitee_person)
+
+ invitation.accept!(invitee_person: invitee_person)
+
+ expect(invitation.status).to eq('accepted')
+ expect(invitation).to be_persisted
+ end
+ end
+
+ describe '#decline!' do
+ it 'sets status to declined' do
+ invitation.decline!
+
+ expect(invitation.status).to eq('declined')
+ expect(invitation).to be_persisted
+ end
+ end
+ end
+
+ describe 'token generation' do
+ it 'automatically generates a token before validation' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ token: nil)
+
+ expect(invitation.token).to be_nil
+ invitation.valid?
+ expect(invitation.token).to be_present
+ end
+
+ it 'does not overwrite existing tokens' do
+ original_token = 'existing-token'
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ token: original_token)
+
+ invitation.valid?
+ expect(invitation.token).to eq(original_token)
+ end
+ end
+
+ describe 'locale handling' do
+ it 'validates locale is in available locales' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'invalid')
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:locale]).to include('is not included in the list')
+ end
+
+ it 'accepts valid locales' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'es')
+
+ expect(invitation).to be_valid
+ end
+
+ it 'requires locale to be present' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: nil)
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:locale]).to include("can't be blank")
+ end
+ end
+end
diff --git a/spec/models/better_together/event_invitation_enhanced_spec.rb b/spec/models/better_together/event_invitation_enhanced_spec.rb
new file mode 100644
index 000000000..ea429bee6
--- /dev/null
+++ b/spec/models/better_together/event_invitation_enhanced_spec.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether # rubocop:todo Metrics/ModuleLength
+ RSpec.describe EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+ let(:invitee_person) { create(:better_together_person) }
+ let(:invitee_email) { 'test@example.com' }
+
+ describe 'validations' do
+ context 'when inviting an existing person' do
+ subject(:invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ it { is_expected.to be_valid }
+
+ it 'prevents duplicate person invitations' do
+ invitation.save!
+ duplicate = build(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitation.invitee)
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee]).to include('has already been invited to this event')
+ end
+ end
+
+ context 'when inviting by email' do
+ subject(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: invitee_email)
+ end
+
+ it { is_expected.to be_valid }
+
+ it 'prevents duplicate email invitations' do
+ invitation.save!
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: invitee_email)
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee_email]).to include('has already been invited to this event')
+ end
+ end
+
+ context 'when both invitee and invitee_email are blank' do
+ subject(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+ end
+
+ it { is_expected.not_to be_valid }
+
+ it 'has the correct error message' do
+ invitation.valid?
+ expect(invitation.errors[:base]).to include('Either invitee or invitee_email must be present')
+ end
+ end
+ end
+
+ describe 'invitation types' do
+ context 'with person invitation' do
+ subject(:invitation) do
+ build(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ it 'identifies as person invitation' do
+ expect(invitation.for_existing_user?).to be true
+ expect(invitation.for_email?).to be false
+ expect(invitation.invitation_type).to eq :person
+ end
+ end
+
+ context 'with email invitation' do
+ subject(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: invitee_email)
+ end
+
+ it 'identifies as email invitation' do
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be true
+ expect(invitation.invitation_type).to eq :email
+ end
+ end
+ end
+
+ describe 'scopes' do
+ let!(:person_invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ let!(:email_invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: invitee_email)
+ end
+
+ describe '.for_existing_users' do
+ it 'returns only person invitations' do
+ expect(described_class.for_existing_users).to include(person_invitation)
+ expect(described_class.for_existing_users).not_to include(email_invitation)
+ end
+ end
+
+ describe '.for_email_addresses' do
+ it 'returns only email invitations' do
+ expect(described_class.for_email_addresses).to include(email_invitation)
+ expect(described_class.for_email_addresses).not_to include(person_invitation)
+ end
+ end
+ end
+
+ describe '#after_accept!' do
+ let(:community) { configure_host_platform&.community }
+ let(:community_role) { BetterTogether::Role.find_by(identifier: 'community_member') }
+
+ context 'with person invitation' do
+ subject(:invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ it 'creates event attendance' do
+ expect { invitation.after_accept!(invitee_person: invitee_person) }
+ .to change(EventAttendance, :count).by(1)
+
+ attendance = EventAttendance.last
+ expect(attendance.person).to eq(invitee_person)
+ expect(attendance.event).to eq(event)
+ expect(attendance.status).to eq('going')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/event_invitation_types_scopes_spec.rb b/spec/models/better_together/event_invitation_types_scopes_spec.rb
new file mode 100644
index 000000000..a4b56f6de
--- /dev/null
+++ b/spec/models/better_together/event_invitation_types_scopes_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+ let(:invitee_person) { create(:better_together_person, locale: 'es') }
+
+ describe 'invitation type helpers' do
+ context 'person invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+ end
+
+ it 'correctly identifies as person invitation' do
+ expect(invitation.invitation_type).to eq(:person)
+ expect(invitation.for_existing_user?).to be true
+ expect(invitation.for_email?).to be false
+ end
+ end
+
+ context 'email invitation' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+ end
+
+ it 'correctly identifies as email invitation' do
+ expect(invitation.invitation_type).to eq(:email)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be true
+ end
+ end
+
+ context 'unknown invitation type' do
+ let(:invitation) do
+ build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+ end
+
+ it 'identifies as unknown when both are nil' do
+ expect(invitation.invitation_type).to eq(:unknown)
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be false
+ end
+ end
+ end
+
+ describe 'enhanced scopes' do
+ let!(:person_invitation) do
+ create(:better_together_event_invitation,
+ :with_invitee,
+ invitable: event,
+ inviter: inviter)
+ end
+
+ let!(:email_invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'email@example.com')
+ end
+
+ describe '.for_existing_users' do
+ it 'returns only invitations with an invitee person' do
+ results = described_class.for_existing_users
+
+ expect(results).to include(person_invitation)
+ expect(results).not_to include(email_invitation)
+ end
+ end
+
+ describe '.for_email_addresses' do
+ it 'returns only invitations with an email address but no invitee' do
+ results = described_class.for_email_addresses
+
+ expect(results).to include(email_invitation)
+ expect(results).not_to include(person_invitation)
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/event_invitation_url_acceptance_spec.rb b/spec/models/better_together/event_invitation_url_acceptance_spec.rb
new file mode 100644
index 000000000..9f293bb5a
--- /dev/null
+++ b/spec/models/better_together/event_invitation_url_acceptance_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+
+ describe 'enhanced URL generation' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: 'test@example.com',
+ locale: 'es',
+ token: 'test-token-123')
+ end
+
+ it 'generates URLs that link directly to the event with invitation token' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ expect(url).to include(event.slug)
+ expect(uri.query).to include('invitation_token=test-token-123')
+
+ if uri.query&.include?('locale=')
+ expect(uri.query).to include('locale=es')
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(url).not_to include('/invitations/')
+ end
+
+ it 'includes proper locale in generated URLs' do
+ url = invitation.url_for_review
+ uri = URI.parse(url)
+
+ params = uri.query ? CGI.parse(uri.query) : {}
+
+ if params['locale'].present?
+ expect(params['locale']).to eq(['es'])
+ else
+ expect(uri.path).to match('/es/')
+ end
+
+ expect(params['invitation_token']).to eq(['test-token-123'])
+ end
+ end
+
+ describe 'enhanced acceptance flow' do
+ let(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: create(:better_together_person),
+ status: 'pending')
+ end
+
+ describe '#after_accept!' do
+ it 'creates event attendance with going status' do
+ expect do
+ invitation.after_accept!(invitee_person: invitation.invitee)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitation.invitee)
+ expect(attendance.event).to eq(event)
+ expect(attendance.status).to eq('going')
+ end
+
+ it 'does not create duplicate community memberships' do
+ community = configure_host_platform&.community
+ community.person_community_memberships.create!(
+ member: invitation.invitee,
+ role: BetterTogether::Role.find_by(identifier: 'community_member')
+ )
+
+ expect do
+ invitation.after_accept!(invitee_person: invitation.invitee)
+ end.not_to change(community.person_community_memberships, :count)
+ end
+
+ it 'handles missing community gracefully' do
+ allow(event.creator).to receive(:primary_community).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitation.invitee)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+
+ attendance = BetterTogether::EventAttendance.last
+ expect(attendance.person).to eq(invitation.invitee)
+ end
+
+ it 'handles missing event creator gracefully' do
+ allow(event).to receive(:creator).and_return(nil)
+
+ expect do
+ invitation.after_accept!(invitee_person: invitation.invitee)
+ end.to change(BetterTogether::EventAttendance, :count).by(1)
+ end
+ end
+
+ describe '#accept!' do
+ it 'sets status to accepted and calls after_accept!' do
+ expect(invitation).to receive(:after_accept!).with(invitee_person: invitation.invitee)
+
+ invitation.accept!(invitee_person: invitation.invitee)
+
+ expect(invitation.status).to eq('accepted')
+ expect(invitation).to be_persisted
+ end
+ end
+
+ describe '#decline!' do
+ it 'sets status to declined' do
+ invitation.decline!
+
+ expect(invitation.status).to eq('declined')
+ expect(invitation).to be_persisted
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/event_invitation_validations_spec.rb b/spec/models/better_together/event_invitation_validations_spec.rb
new file mode 100644
index 000000000..795f4c93f
--- /dev/null
+++ b/spec/models/better_together/event_invitation_validations_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::EventInvitation do
+ let(:event) { create(:better_together_event) }
+ let(:inviter) { create(:better_together_person) }
+ let(:invitee_person) { create(:better_together_person) }
+
+ describe 'enhanced validations' do
+ context 'invitation uniqueness' do
+ it 'prevents duplicate person invitations for the same event' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee]).to include('has already been invited to this event')
+ end
+
+ it 'prevents duplicate email invitations for the same event' do
+ email = 'test@example.com'
+
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ duplicate = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee_email: email,
+ status: 'pending')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:invitee_email]).to include('has already been invited to this event')
+ end
+
+ it 'allows duplicate invitations if previous one was declined' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'declined')
+
+ new_invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(new_invitation).to be_valid
+ end
+
+ it 'allows same person to be invited to different events' do
+ other_event = create(:better_together_event)
+
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ other_invitation = build(:better_together_event_invitation,
+ invitable: other_event,
+ inviter: inviter,
+ invitee: invitee_person,
+ status: 'pending')
+
+ expect(other_invitation).to be_valid
+ end
+ end
+
+ context 'invitee presence validation' do
+ it 'requires either invitee or invitee_email' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: nil)
+
+ expect(invitation).not_to be_valid
+ expect(invitation.errors[:base]).to include('Either invitee or invitee_email must be present')
+ end
+
+ it 'accepts invitation with invitee only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: invitee_person,
+ invitee_email: nil)
+
+ expect(invitation).to be_valid
+ end
+
+ it 'accepts invitation with invitee_email only' do
+ invitation = build(:better_together_event_invitation,
+ invitable: event,
+ inviter: inviter,
+ invitee: nil,
+ invitee_email: 'test@example.com')
+
+ expect(invitation).to be_valid
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/event_spec.rb b/spec/models/better_together/event_spec.rb
index f0b764c5d..fbfcb9f67 100644
--- a/spec/models/better_together/event_spec.rb
+++ b/spec/models/better_together/event_spec.rb
@@ -246,7 +246,7 @@ module BetterTogether # rubocop:todo Metrics/ModuleLength
describe '#host_community' do
let(:event) { build(:event) }
- context 'when host community exists' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when host community exists' do
let!(:host_community) { create(:community, :host) }
it 'returns the host community' do
diff --git a/spec/models/better_together/geography/locatable_location_spec.rb b/spec/models/better_together/geography/locatable_location_spec.rb
index 45c0cff1f..3d5ab0c1d 100644
--- a/spec/models/better_together/geography/locatable_location_spec.rb
+++ b/spec/models/better_together/geography/locatable_location_spec.rb
@@ -218,12 +218,12 @@ module Geography # rubocop:todo Metrics/ModuleLength
end
end
- context 'when context is a Person with user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when context is a Person with user' do
let!(:person_contact_detail) do
create(:better_together_contact_detail, contactable: person)
end
- let!(:person_address) do # rubocop:todo RSpec/LetSetup
+ let!(:person_address) do
create(:better_together_address, privacy: 'private', contact_detail: person_contact_detail)
end
@@ -241,7 +241,7 @@ module Geography # rubocop:todo Metrics/ModuleLength
end
end
- context 'when context is a Person without user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when context is a Person without user' do
let(:person_without_user) { create(:better_together_person) }
it 'returns only public addresses' do
@@ -252,7 +252,7 @@ module Geography # rubocop:todo Metrics/ModuleLength
end
end
- context 'when context is a Community' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when context is a Community' do
let!(:community_contact_detail) do
create(:better_together_contact_detail, contactable: community)
end
@@ -293,7 +293,7 @@ module Geography # rubocop:todo Metrics/ModuleLength
end
end
- context 'when context is a Person with user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when context is a Person with user' do
let!(:person_building) do
create(:better_together_infrastructure_building,
creator: person,
@@ -317,7 +317,7 @@ module Geography # rubocop:todo Metrics/ModuleLength
end
end
- context 'when context is a Person without user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when context is a Person without user' do
let(:person_without_user) { create(:better_together_person) }
it 'returns only public buildings' do
diff --git a/spec/models/better_together/infrastructure/building_spec.rb b/spec/models/better_together/infrastructure/building_spec.rb
index be1466360..65d005969 100644
--- a/spec/models/better_together/infrastructure/building_spec.rb
+++ b/spec/models/better_together/infrastructure/building_spec.rb
@@ -53,7 +53,7 @@ module BetterTogether
describe '#ensure_floor' do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a floor if none exist' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a floor if none exist' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
building_no_floors = create(:building)
building_no_floors.reload
diff --git a/spec/models/better_together/joatu/agreement_spec.rb b/spec/models/better_together/joatu/agreement_spec.rb
index 7000cbe24..1c2619080 100644
--- a/spec/models/better_together/joatu/agreement_spec.rb
+++ b/spec/models/better_together/joatu/agreement_spec.rb
@@ -36,7 +36,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'prevents rejecting after accepted or already rejected' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'prevents rejecting after accepted or already rejected' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
agreement = create(:better_together_joatu_agreement, offer:, request:)
agreement.accept!
@@ -49,7 +49,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'enforces only one accepted agreement per offer and per request' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'enforces only one accepted agreement per offer and per request' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
offer2 = create(:better_together_joatu_offer, creator: creator_a)
request2 = create(:better_together_joatu_request, creator: creator_b)
diff --git a/spec/models/better_together/joatu/offer_spec.rb b/spec/models/better_together/joatu/offer_spec.rb
index 63007876b..7d0e774e2 100644
--- a/spec/models/better_together/joatu/offer_spec.rb
+++ b/spec/models/better_together/joatu/offer_spec.rb
@@ -32,7 +32,7 @@ module Joatu
end
describe 'translations validation side-effects' do
- it 'does not instantiate blank string translations for other locales when assigning name_en and validating' do # rubocop:disable RSpec/ExampleLength
+ it 'does not instantiate blank string translations for other locales when assigning name_en and validating' do
prev_locales = I18n.available_locales
begin
diff --git a/spec/models/better_together/metrics/page_view_spec.rb b/spec/models/better_together/metrics/page_view_spec.rb
index fc0f5718b..c56ed5935 100644
--- a/spec/models/better_together/metrics/page_view_spec.rb
+++ b/spec/models/better_together/metrics/page_view_spec.rb
@@ -8,7 +8,7 @@ module BetterTogether
let(:locale) { 'en' }
# rubocop:todo RSpec/MultipleExpectations
- it 'normalizes page_url to exclude query strings' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'normalizes page_url to exclude query strings' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
page_view = described_class.new(
page_url: 'http://127.0.0.1:3000/path?foo=bar',
@@ -21,7 +21,7 @@ module BetterTogether
end
# rubocop:todo RSpec/MultipleExpectations
- it 'rejects URLs containing sensitive parameters' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'rejects URLs containing sensitive parameters' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
page_view = described_class.new(
page_url: 'http://127.0.0.1:3000/path?token=abc',
diff --git a/spec/models/better_together/navigation_item_spec.rb b/spec/models/better_together/navigation_item_spec.rb
index 62e5edab9..fab07b7ab 100644
--- a/spec/models/better_together/navigation_item_spec.rb
+++ b/spec/models/better_together/navigation_item_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe BetterTogether::NavigationItem, type: :model do
+RSpec.describe BetterTogether::NavigationItem do
let(:navigation_area) { create(:navigation_area) }
context 'title fallbacks' do
@@ -58,8 +58,6 @@
# spec/models/better_together/navigation_item_spec.rb
-require 'rails_helper'
-
module BetterTogether # rubocop:todo Metrics/ModuleLength
RSpec.describe NavigationItem do
subject(:navigation_item) { build(:better_together_navigation_item) }
@@ -166,7 +164,7 @@ module BetterTogether # rubocop:todo Metrics/ModuleLength
end
context 'when linkable is not present' do
- context 'and url is set' do # rubocop:todo RSpec/ContextWording
+ context 'and url is set' do
before { navigation_item.url = 'http://example.com' }
it 'returns the set url' do
@@ -174,7 +172,7 @@ module BetterTogether # rubocop:todo Metrics/ModuleLength
end
end
- context 'and url is not set' do # rubocop:todo RSpec/ContextWording
+ context 'and url is not set' do
before { navigation_item.url = nil }
it 'returns default url (#)' do
diff --git a/spec/models/better_together/person_block_spec.rb b/spec/models/better_together/person_block_spec.rb
index 8d79db3fe..6b0204114 100644
--- a/spec/models/better_together/person_block_spec.rb
+++ b/spec/models/better_together/person_block_spec.rb
@@ -13,7 +13,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'does not allow blocking platform managers' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'does not allow blocking platform managers' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
platform = create(:platform)
role = BetterTogether::Role.find_by(identifier: 'platform_manager', resource_type: 'BetterTogether::Platform') ||
diff --git a/spec/models/better_together/platform_invitation_spec.rb b/spec/models/better_together/platform_invitation_spec.rb
index d093a1101..0f475d028 100644
--- a/spec/models/better_together/platform_invitation_spec.rb
+++ b/spec/models/better_together/platform_invitation_spec.rb
@@ -35,7 +35,7 @@ module BetterTogether # rubocop:todo Metrics/ModuleLength
it { is_expected.to validate_presence_of(:status) }
it { is_expected.to validate_uniqueness_of(:token) }
- context 'status transitions' do # rubocop:todo RSpec/ContextWording
+ context 'status transitions' do
it 'allows valid transitions' do # rubocop:todo RSpec/MultipleExpectations
platform_invitation.status = 'pending'
platform_invitation.save!
@@ -100,7 +100,7 @@ module BetterTogether # rubocop:todo Metrics/ModuleLength
describe '.not_expired' do
# rubocop:todo RSpec/MultipleExpectations
- it 'returns invitations that are not expired or have no expiration' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'returns invitations that are not expired or have no expiration' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
future_invitation = create(:better_together_platform_invitation, valid_until: 1.day.from_now)
nil_invitation = create(:better_together_platform_invitation, valid_until: nil)
diff --git a/spec/models/better_together/post_spec.rb b/spec/models/better_together/post_spec.rb
index 8448b2fe3..97a81f0f7 100644
--- a/spec/models/better_together/post_spec.rb
+++ b/spec/models/better_together/post_spec.rb
@@ -24,7 +24,7 @@
end
describe 'after_create #add_creator_as_author' do
- it 'creates an authorship for the creator_id' do # rubocop:todo RSpec/ExampleLength
+ it 'creates an authorship for the creator_id' do
creator = create(:better_together_person)
post = build(:better_together_post)
# Ensure no prebuilt authorships from the factory
diff --git a/spec/models/concerns/positioned_spec.rb b/spec/models/concerns/positioned_spec.rb
index 1fb96c450..223f46908 100644
--- a/spec/models/concerns/positioned_spec.rb
+++ b/spec/models/concerns/positioned_spec.rb
@@ -29,7 +29,7 @@ def position_scope
# rubocop:enable RSpec/RemoveConst
end
- it 'treats blank scope values as nil when computing max position' do # rubocop:disable RSpec/ExampleLength
+ it 'treats blank scope values as nil when computing max position' do
# Ensure there are two existing top-level records (parent_id = nil)
PositionedTest.create!(position: 0)
PositionedTest.create!(position: 1)
@@ -42,7 +42,7 @@ def position_scope
expect(new_rec.position).to eq(2)
end
- it 'uses the exact scope value when provided (non-blank)' do # rubocop:disable RSpec/ExampleLength
+ it 'uses the exact scope value when provided (non-blank)' do
# Create items under parent_id = 5
PositionedTest.create!(parent_id: 5, position: 0)
PositionedTest.create!(parent_id: 5, position: 1)
diff --git a/spec/models/translatable_attachments_api_spec.rb b/spec/models/translatable_attachments_api_spec.rb
index 8a352429a..d82b73ff7 100644
--- a/spec/models/translatable_attachments_api_spec.rb
+++ b/spec/models/translatable_attachments_api_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# rubocop:disable RSpec/ExampleLength, RSpec/DescribeClass
+# rubocop:disable RSpec/DescribeClass
require 'rails_helper'
diff --git a/spec/models/translatable_attachments_writer_spec.rb b/spec/models/translatable_attachments_writer_spec.rb
index cc243e143..ddc57f3b0 100644
--- a/spec/models/translatable_attachments_writer_spec.rb
+++ b/spec/models/translatable_attachments_writer_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# rubocop:disable RSpec/ExampleLength, RSpec/DescribeClass
+# rubocop:disable RSpec/DescribeClass
require 'rails_helper'
diff --git a/spec/notifiers/better_together/joatu/agreement_notifier_spec.rb b/spec/notifiers/better_together/joatu/agreement_notifier_spec.rb
index a2514d60e..84a889239 100644
--- a/spec/notifiers/better_together/joatu/agreement_notifier_spec.rb
+++ b/spec/notifiers/better_together/joatu/agreement_notifier_spec.rb
@@ -21,7 +21,7 @@ module Joatu
end
# rubocop:todo RSpec/MultipleExpectations
- it 'builds message with offer and request names' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'builds message with offer and request names' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
agreement = build(:joatu_agreement, offer:, request:)
notifier = described_class.new(record: agreement)
diff --git a/spec/notifiers/better_together/joatu/agreement_status_notifier_spec.rb b/spec/notifiers/better_together/joatu/agreement_status_notifier_spec.rb
index 5c86bc1d9..84d123c8b 100644
--- a/spec/notifiers/better_together/joatu/agreement_status_notifier_spec.rb
+++ b/spec/notifiers/better_together/joatu/agreement_status_notifier_spec.rb
@@ -5,7 +5,7 @@
module BetterTogether
module Joatu
# rubocop:disable Metrics/BlockLength
- RSpec.describe AgreementStatusNotifier do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ RSpec.describe AgreementStatusNotifier do
let(:recipient) { double('Person') } # rubocop:todo RSpec/VerifiedDoubles
let(:offer) { double('Offer', name: 'Offer') } # rubocop:todo RSpec/VerifiedDoubles
let(:request) { double('Request', name: 'Request') } # rubocop:todo RSpec/VerifiedDoubles
diff --git a/spec/notifiers/better_together/new_message_notifier_spec.rb b/spec/notifiers/better_together/new_message_notifier_spec.rb
index 113fa66a8..9c46fb94e 100644
--- a/spec/notifiers/better_together/new_message_notifier_spec.rb
+++ b/spec/notifiers/better_together/new_message_notifier_spec.rb
@@ -4,7 +4,7 @@
module BetterTogether
# rubocop:disable Metrics/BlockLength
- RSpec.describe NewMessageNotifier do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ RSpec.describe NewMessageNotifier do
let(:recipient) { double('Person') } # rubocop:todo RSpec/VerifiedDoubles
let(:conversation) { double('Conversation', id: 1, title: 'Chat') } # rubocop:todo RSpec/VerifiedDoubles
let(:sender) { double('Person', name: 'Alice') } # rubocop:todo RSpec/VerifiedDoubles
diff --git a/spec/policies/better_together/checklist_item_policy_spec.rb b/spec/policies/better_together/checklist_item_policy_spec.rb
index bce85ad4a..5fa8cf48d 100644
--- a/spec/policies/better_together/checklist_item_policy_spec.rb
+++ b/spec/policies/better_together/checklist_item_policy_spec.rb
@@ -15,7 +15,7 @@
subject { described_class.new(user, item).create? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -23,7 +23,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it { is_expected.to be false }
@@ -35,7 +35,7 @@
subject { described_class.new(user, item).update? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -43,7 +43,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'creator' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'creator' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { creator_user }
it { is_expected.to be true }
@@ -51,7 +51,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it { is_expected.to be false }
@@ -63,7 +63,7 @@
subject { described_class.new(user, item).destroy? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -71,7 +71,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'creator' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'creator' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { creator_user }
it { is_expected.to be false }
diff --git a/spec/policies/better_together/checklist_policy_spec.rb b/spec/policies/better_together/checklist_policy_spec.rb
index 292d139dc..06db8e07a 100644
--- a/spec/policies/better_together/checklist_policy_spec.rb
+++ b/spec/policies/better_together/checklist_policy_spec.rb
@@ -14,7 +14,7 @@
subject { described_class.new(user, BetterTogether::Checklist).create? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -22,7 +22,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it { is_expected.to be false }
@@ -34,7 +34,7 @@
subject { described_class.new(user, checklist).update? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -42,7 +42,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'creator' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'creator' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { creator_user }
it { is_expected.to be true }
@@ -50,7 +50,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it { is_expected.to be false }
@@ -62,7 +62,7 @@
subject { described_class.new(user, checklist).destroy? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it { is_expected.to be true }
@@ -70,7 +70,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'creator' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'creator' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { creator_user }
it { is_expected.to be false }
diff --git a/spec/policies/better_together/conversation_policy_spec.rb b/spec/policies/better_together/conversation_policy_spec.rb
index 8d9409ffd..09aa40c19 100644
--- a/spec/policies/better_together/conversation_policy_spec.rb
+++ b/spec/policies/better_together/conversation_policy_spec.rb
@@ -5,7 +5,7 @@
RSpec.describe BetterTogether::ConversationPolicy, type: :policy do
include RequestSpecHelper
- let!(:host_platform) { configure_host_platform } # rubocop:todo RSpec/LetSetup
+ let!(:host_platform) { configure_host_platform }
let!(:manager_user) { create(:user, :confirmed, :platform_manager, password: 'password12345') }
let!(:manager_person) { manager_user.person }
@@ -25,7 +25,7 @@
end
end
- context 'when agent is a regular member' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when agent is a regular member' do
let!(:regular_user) { create(:user, :confirmed, password: 'password12345') }
it 'includes platform managers and opted-in members, but not non-opted members' do
@@ -38,7 +38,7 @@
end
end
- describe '#create? with participants kwarg' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe '#create? with participants kwarg' do
let(:regular_user) { create(:user, :confirmed, password: 'password12345') }
let(:policy) { described_class.new(regular_user, BetterTogether::Conversation.new) }
diff --git a/spec/policies/better_together/joatu/agreement_policy_spec.rb b/spec/policies/better_together/joatu/agreement_policy_spec.rb
index 6d630cfc1..6e91e72fc 100644
--- a/spec/policies/better_together/joatu/agreement_policy_spec.rb
+++ b/spec/policies/better_together/joatu/agreement_policy_spec.rb
@@ -15,11 +15,11 @@
request: create(:better_together_joatu_request, creator: request_creator.person))
end
- context 'as offer creator' do # rubocop:todo RSpec/ContextWording
+ context 'as offer creator' do
let(:user) { offer_creator }
# rubocop:todo RSpec/MultipleExpectations
- it 'permits participant actions' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'permits participant actions' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
expect(policy.show?).to be(true)
expect(policy.create?).to be(true)
@@ -30,11 +30,11 @@
end
end
- context 'as request creator' do # rubocop:todo RSpec/ContextWording
+ context 'as request creator' do
let(:user) { request_creator }
# rubocop:todo RSpec/MultipleExpectations
- it 'permits participant actions' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'permits participant actions' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
expect(policy.show?).to be(true)
expect(policy.create?).to be(true)
@@ -45,11 +45,11 @@
end
end
- context 'as unrelated user' do # rubocop:todo RSpec/ContextWording
+ context 'as unrelated user' do
let(:user) { stranger }
# rubocop:todo RSpec/MultipleExpectations
- it 'forbids participant actions' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'forbids participant actions' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
expect(policy.show?).to be(false)
expect(policy.create?).to be(false)
diff --git a/spec/policies/better_together/joatu/offer_policy_spec.rb b/spec/policies/better_together/joatu/offer_policy_spec.rb
index 9c47236ba..09846e6a6 100644
--- a/spec/policies/better_together/joatu/offer_policy_spec.rb
+++ b/spec/policies/better_together/joatu/offer_policy_spec.rb
@@ -60,7 +60,7 @@
let!(:offer2) { create(:better_together_joatu_offer) } # rubocop:todo RSpec/IndexedLet
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'authenticated user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'authenticated user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it 'includes all offers' do
@@ -70,7 +70,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'guest' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'guest' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { nil }
it 'returns none' do
diff --git a/spec/policies/better_together/joatu/request_policy_spec.rb b/spec/policies/better_together/joatu/request_policy_spec.rb
index bef9c9595..46c54b491 100644
--- a/spec/policies/better_together/joatu/request_policy_spec.rb
+++ b/spec/policies/better_together/joatu/request_policy_spec.rb
@@ -60,7 +60,7 @@
let!(:req2) { create(:better_together_joatu_request) } # rubocop:todo RSpec/IndexedLet
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'authenticated user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'authenticated user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it 'includes all requests' do
@@ -70,7 +70,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'guest' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'guest' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { nil }
it 'returns none' do
diff --git a/spec/policies/better_together/page_policy_spec.rb b/spec/policies/better_together/page_policy_spec.rb
index 866086b59..ef947ccc2 100644
--- a/spec/policies/better_together/page_policy_spec.rb
+++ b/spec/policies/better_together/page_policy_spec.rb
@@ -22,12 +22,12 @@
subject { described_class.new(user, page).show? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'for published public pages' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'for published public pages' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:page) { public_published }
# rubocop:todo RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/NestedGroups
- context 'anyone' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'anyone' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:user) { nil }
@@ -38,10 +38,10 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'for private or unpublished pages' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'for private or unpublished pages' do # rubocop:todo RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/NestedGroups
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:user) { manager_user }
let(:page) { private_unpublished }
@@ -52,7 +52,7 @@
# rubocop:todo RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/NestedGroups
- context 'author' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'author' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:user) { author_user }
let(:page) { private_unpublished }
@@ -63,7 +63,7 @@
# rubocop:todo RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/NestedGroups
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups
# rubocop:enable RSpec/NestedGroups
let(:user) { normal_user }
let(:page) { private_unpublished }
@@ -79,7 +79,7 @@
subject { described_class.new(user, page).update? }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
let(:page) { public_unpublished }
@@ -88,7 +88,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'author' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'author' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { author_user }
let(:page) { private_unpublished }
@@ -97,7 +97,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
let(:page) { public_published }
@@ -110,7 +110,7 @@
subject { described_class::Scope.new(user, BetterTogether::Page).resolve }
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'manager' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'manager' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { manager_user }
it 'includes all pages' do
@@ -120,7 +120,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'author' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'author' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { author_user }
it 'includes authored and published public pages' do
@@ -131,7 +131,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'normal user' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'normal user' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { normal_user }
it 'includes published public pages and nothing else is guaranteed' do
@@ -144,7 +144,7 @@
# rubocop:enable RSpec/MultipleMemoizedHelpers
# rubocop:todo RSpec/MultipleMemoizedHelpers
- context 'guest' do # rubocop:todo RSpec/ContextWording, RSpec/MultipleMemoizedHelpers
+ context 'guest' do # rubocop:todo RSpec/MultipleMemoizedHelpers
let(:user) { nil }
it 'includes published public pages and nothing else is guaranteed' do
diff --git a/spec/policies/better_together/person_block_policy_spec.rb b/spec/policies/better_together/person_block_policy_spec.rb
index 11f5be862..3f4b66886 100644
--- a/spec/policies/better_together/person_block_policy_spec.rb
+++ b/spec/policies/better_together/person_block_policy_spec.rb
@@ -19,7 +19,7 @@
expect(described_class.new(user, record).create?).to be true
end
- it 'denies when blocked is a platform manager' do # rubocop:todo RSpec/ExampleLength
+ it 'denies when blocked is a platform manager' do
host_platform = create(:better_together_platform, :host, privacy: 'public')
manager_user = create(:better_together_user, :confirmed)
platform_manager_role = BetterTogether::Role.find_by(identifier: 'platform_manager')
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index eabe23c6f..e63803af9 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -147,6 +147,11 @@ def build_with_retry(times: 3)
DatabaseCleaner.clean
end
+ # Reset locale to English after each test to prevent test isolation issues
+ config.after do
+ I18n.locale = I18n.default_locale
+ end
+
# Ensure essential data is available after JS tests
config.after(:each, :js) do
# Check if essential data exists, re-seed if missing
diff --git a/spec/requests/better_together/checklist_items_nested_spec.rb b/spec/requests/better_together/checklist_items_nested_spec.rb
index d97b07711..38e0953b1 100644
--- a/spec/requests/better_together/checklist_items_nested_spec.rb
+++ b/spec/requests/better_together/checklist_items_nested_spec.rb
@@ -42,7 +42,7 @@
end
end
- context 'when reordering siblings' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ context 'when reordering siblings' do
let!(:a) { create(:better_together_checklist_item, checklist: checklist, parent: parent, position: 0) }
let!(:b) { create(:better_together_checklist_item, checklist: checklist, parent: parent, position: 1) }
let!(:top) { create(:better_together_checklist_item, checklist: checklist, position: 0) }
diff --git a/spec/requests/better_together/checklist_items_reorder_spec.rb b/spec/requests/better_together/checklist_items_reorder_spec.rb
index 67f7358d9..32c70971a 100644
--- a/spec/requests/better_together/checklist_items_reorder_spec.rb
+++ b/spec/requests/better_together/checklist_items_reorder_spec.rb
@@ -11,7 +11,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'reorders items' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'reorders items' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
checklist = create(:better_together_checklist, creator: platform_manager.person)
item1 = create(:better_together_checklist_item, checklist: checklist, position: 0)
diff --git a/spec/requests/better_together/checklists_spec.rb b/spec/requests/better_together/checklists_spec.rb
index 710c886fc..81cff553e 100644
--- a/spec/requests/better_together/checklists_spec.rb
+++ b/spec/requests/better_together/checklists_spec.rb
@@ -30,7 +30,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'updates a checklist' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates a checklist' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
checklist = create(:better_together_checklist,
creator: BetterTogether::User.find_by(email: 'manager@example.test').person)
@@ -57,7 +57,7 @@
describe 'authorization for update/destroy as creator' do
# rubocop:todo RSpec/MultipleExpectations
- it 'allows creator to update their checklist' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'allows creator to update their checklist' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
user = create(:better_together_user, :confirmed, password: 'password12345')
checklist = create(:better_together_checklist, creator: user.person)
diff --git a/spec/requests/better_together/communities_controller_spec.rb b/spec/requests/better_together/communities_controller_spec.rb
index 6f18e6f2d..1f867c32d 100644
--- a/spec/requests/better_together/communities_controller_spec.rb
+++ b/spec/requests/better_together/communities_controller_spec.rb
@@ -21,7 +21,7 @@
describe 'PATCH /:locale/communities/:id' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates and redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates and redirects' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
community = create(:better_together_community,
creator: BetterTogether::User.find_by(email: 'manager@example.test').person)
diff --git a/spec/requests/better_together/conversation_message_protection_spec.rb b/spec/requests/better_together/conversation_message_protection_spec.rb
index b7f0d4a72..7a21ae7fb 100644
--- a/spec/requests/better_together/conversation_message_protection_spec.rb
+++ b/spec/requests/better_together/conversation_message_protection_spec.rb
@@ -5,7 +5,6 @@
RSpec.describe 'Conversation message protection' do
include RequestSpecHelper
- # rubocop:todo RSpec/ExampleLength
it "prevents a user from altering another user's message via conversation update" do
# rubocop:enable RSpec/MultipleExpectations
# Setup: ensure host platform exists and create users with known passwords
diff --git a/spec/requests/better_together/conversations_create_with_message_spec.rb b/spec/requests/better_together/conversations_create_with_message_spec.rb
index b0c937f4c..dd3a7bb54 100644
--- a/spec/requests/better_together/conversations_create_with_message_spec.rb
+++ b/spec/requests/better_together/conversations_create_with_message_spec.rb
@@ -16,7 +16,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates conversation and nested message with sender set' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates conversation and nested message with sender set' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
user = BetterTogether::User.find_by(email: 'user@example.test')
person = user.person || create(:better_together_person, user: user)
diff --git a/spec/requests/better_together/conversations_request_spec.rb b/spec/requests/better_together/conversations_request_spec.rb
index efceb81aa..dd44dcbf0 100644
--- a/spec/requests/better_together/conversations_request_spec.rb
+++ b/spec/requests/better_together/conversations_request_spec.rb
@@ -14,7 +14,7 @@
let!(:non_opted_person) { create(:better_together_person, name: 'Non Opted User') }
describe 'GET /conversations/new' do
- context 'as a regular member', :as_user do # rubocop:todo RSpec/ContextWording
+ context 'as a regular member', :as_user do
it 'lists platform managers and opted-in members, but excludes non-opted members' do
# rubocop:enable RSpec/MultipleExpectations
get better_together.new_conversation_path(locale: I18n.default_locale)
@@ -27,7 +27,7 @@
end
end
- context 'as a platform manager', :as_platform_manager do # rubocop:todo RSpec/ContextWording
+ context 'as a platform manager', :as_platform_manager do
it 'lists all people as available participants' do # rubocop:todo RSpec/MultipleExpectations
get better_together.new_conversation_path(locale: I18n.default_locale)
expect(response).to have_http_status(:ok)
@@ -39,8 +39,7 @@
end
describe 'POST /conversations' do
- context 'as a regular member', :as_user do # rubocop:todo RSpec/ContextWording
- # rubocop:todo RSpec/ExampleLength
+ context 'as a regular member', :as_user do
# rubocop:todo RSpec/MultipleExpectations
it 'creates conversation with permitted participants (opted-in) and excludes non-permitted' do
# rubocop:enable RSpec/MultipleExpectations
@@ -80,7 +79,7 @@
end
describe 'GET /conversations/:id' do
- context 'as a non-participant', :as_user do # rubocop:todo RSpec/ContextWording
+ context 'as a non-participant', :as_user do
it 'returns not found' do
conversation = create('better_together/conversation', creator: manager_user.person).tap do |c|
c.participants << manager_user.person unless c.participants.exists?(manager_user.person.id)
@@ -93,7 +92,7 @@
end
describe 'PATCH /conversations/:id' do
- context 'as a regular member', :as_user do # rubocop:todo RSpec/ContextWording
+ context 'as a regular member', :as_user do
let!(:conversation) do
# Ensure the conversation reflects policy by using the logged-in user's person
user = BetterTogether::User.find_by(email: 'user@example.test')
diff --git a/spec/requests/better_together/enhanced_event_invitation_system_spec.rb b/spec/requests/better_together/enhanced_event_invitation_system_spec.rb
new file mode 100644
index 000000000..88a08b0e0
--- /dev/null
+++ b/spec/requests/better_together/enhanced_event_invitation_system_spec.rb
@@ -0,0 +1,313 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enhanced Event Invitation System' do
+ let(:locale) { I18n.default_locale }
+ let!(:manager_user) { BetterTogether::User.find_by(email: 'manager@example.test') }
+ let!(:user) { BetterTogether::User.find_by(email: 'user@example.test') }
+
+ let!(:event) do
+ BetterTogether::Event.create!(
+ name: 'Community Event',
+ starts_at: 1.week.from_now,
+ identifier: SecureRandom.uuid,
+ privacy: 'public',
+ creator: manager_user.person
+ )
+ end
+
+ describe 'dual-path invitation system', :as_platform_manager do
+ describe 'person-based invitations' do
+ let!(:invitee_person) { create(:better_together_person, locale: 'es') }
+
+ it 'creates invitations with automatic email and locale' do
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_id: invitee_person.id } }
+ end.to change(BetterTogether::EventInvitation, :count).by(1)
+
+ invitation = BetterTogether::EventInvitation.last
+ expect(invitation.invitee).to eq(invitee_person)
+ expect(invitation.invitee_email).to eq(invitee_person.email)
+ expect(invitation.locale).to eq('es')
+ expect(invitation.for_existing_user?).to be true
+ expect(invitation.for_email?).to be false
+ end
+
+ it 'enqueues notifications on correct queue' do
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_id: invitee_person.id } }
+ end.to have_enqueued_job.on_queue(:default)
+ end
+
+ it 'prevents duplicate invitations' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee: invitee_person,
+ invitee_email: invitee_person.email)
+
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_id: invitee_person.id } }
+ end.not_to change(BetterTogether::EventInvitation, :count)
+
+ expect(response).to have_http_status(:redirect)
+ end
+ end
+
+ describe 'email-based invitations' do
+ let(:external_email) { 'external@example.org' }
+
+ it 'creates invitations with specified locale' do
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_email: external_email, locale: 'fr' } }
+ end.to change(BetterTogether::EventInvitation, :count).by(1)
+
+ invitation = BetterTogether::EventInvitation.last
+ expect(invitation.invitee).to be_nil
+ expect(invitation.invitee_email).to eq(external_email)
+ expect(invitation.locale).to eq('fr')
+ expect(invitation.for_existing_user?).to be false
+ expect(invitation.for_email?).to be true
+ end
+
+ it 'prevents duplicate email invitations' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: external_email)
+
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_email: external_email } }
+ end.not_to change(BetterTogether::EventInvitation, :count)
+
+ expect(response).to have_http_status(:redirect)
+ end
+
+ it 'allows invitations to different emails' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'first@example.com')
+
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_email: 'second@example.com' } }
+ end.to change(BetterTogether::EventInvitation, :count).by(1)
+ end
+ end
+ end
+
+ describe 'invitation status display', :as_platform_manager do
+ let!(:invitee_person) { create(:better_together_person) }
+
+ it 'shows pending invitations' do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee: invitee_person,
+ invitee_email: invitee_person.email,
+ status: 'pending')
+
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response.body).to include('Pending')
+ expect(response.body).to include(invitee_person.name)
+ end
+
+ it 'shows accepted invitations' do
+ create(:better_together_event_invitation, :accepted,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee: invitee_person,
+ invitee_email: invitee_person.email)
+
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response.body).to include(invitee_person.name)
+ expect(response.body).to include('Accepted')
+ end
+
+ it 'shows rejected invitations' do
+ create(:better_together_event_invitation, :declined,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee: invitee_person,
+ invitee_email: invitee_person.email)
+
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response.body).to include('Event Invitations')
+ expect(response.body).to include('Declined')
+ expect(response.body).to include('badge bg-danger')
+ end
+ end
+
+ describe 'private event access', :unauthenticated do
+ let!(:private_event) do
+ BetterTogether::Event.create!(
+ name: 'Private Event',
+ starts_at: 1.week.from_now,
+ identifier: SecureRandom.uuid,
+ privacy: 'private',
+ creator: manager_user.person
+ )
+ end
+
+ let!(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: private_event,
+ inviter: manager_user.person,
+ invitee_email: 'invitee@example.com')
+ end
+
+ context 'allows access with valid invitation token' do
+ it 'allows access to private event' do
+ valid_invitation = FactoryBot.create(:better_together_event_invitation,
+ invitable: private_event,
+ inviter: manager_user.person,
+ invitee_email: 'invited@example.com')
+
+ get better_together.event_path(private_event.slug,
+ locale: locale,
+ invitation_token: valid_invitation.token)
+
+ # Valid invitation tokens render the private event page
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(private_event.name)
+ end
+ end
+
+ it 'allows RSVP with valid invitation token' do
+ post better_together.rsvp_going_event_path(private_event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:redirect)
+ expect(response.location).to include('sign-in')
+ end
+
+ it 'allows ICS download with valid invitation token' do
+ get better_together.ics_event_path(private_event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(response.headers['Content-Type']).to include('text/calendar')
+ end
+
+ it 'denies access without invitation token' do
+ get better_together.event_path(private_event.slug, locale: locale)
+ expect(response).to have_http_status(:redirect)
+ end
+
+ it 'denies access with invalid token' do
+ get better_together.event_path(private_event.slug, locale: locale, invitation_token: 'invalid')
+ expect(response).to have_http_status(:redirect)
+ end
+
+ it 'denies access with expired token' do
+ expired_invitation = create(:better_together_event_invitation, :expired,
+ invitable: private_event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.com')
+
+ get better_together.event_path(private_event.slug, locale: locale, invitation_token: expired_invitation.token)
+ expect(response).to have_http_status(:ok) # Expired tokens still allow viewing the event
+ end
+ end
+
+ describe 'platform privacy bypass', :unauthenticated do
+ let!(:private_platform) { BetterTogether::Platform.find_by(host: true) }
+ let!(:public_event) do
+ BetterTogether::Event.create!(
+ name: 'Public Event on Private Platform',
+ starts_at: 1.week.from_now,
+ identifier: SecureRandom.uuid,
+ privacy: 'public',
+ creator: manager_user.person
+ )
+ end
+
+ let!(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: public_event,
+ inviter: manager_user.person,
+ invitee_email: 'external@example.com')
+ end
+
+ before do
+ private_platform.update!(privacy: 'private')
+ end
+
+ after do
+ private_platform.update!(privacy: 'public')
+ end
+
+ it 'allows event access via invitation on private platform' do
+ get better_together.event_path(public_event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(public_event.name)
+ end
+
+ it 'redirects to sign in without invitation token' do
+ get better_together.event_path(public_event.slug, locale: locale)
+ expect(response).to have_http_status(:redirect)
+ expect(response.location).to include('sign-in')
+ end
+ end
+
+ describe 'invitation token validation' do
+ let!(:token_test_event) do
+ BetterTogether::Event.create!(
+ name: 'Private Token Test Event',
+ starts_at: 1.week.from_now,
+ identifier: SecureRandom.uuid,
+ privacy: 'private',
+ creator: manager_user.person
+ )
+ end
+
+ let!(:valid_invitation) do
+ create(:better_together_event_invitation,
+ invitable: token_test_event,
+ inviter: manager_user.person,
+ invitee_email: 'valid@example.com')
+ end
+
+ let!(:declined_invitation) do
+ create(:better_together_event_invitation, :declined,
+ invitable: token_test_event,
+ inviter: manager_user.person,
+ invitee_email: 'declined@example.com')
+ end
+
+ let!(:expired_invitation) do
+ create(:better_together_event_invitation, :expired,
+ invitable: token_test_event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.com')
+ end
+
+ it 'accepts valid tokens' do
+ get better_together.event_path(token_test_event.slug, locale: locale, invitation_token: valid_invitation.token)
+ expect(response).to have_http_status(:ok) # System allows valid token access
+ end
+
+ it 'handles declined tokens gracefully' do
+ get better_together.event_path(token_test_event.slug, locale: locale, invitation_token: declined_invitation.token)
+ expect(response).to have_http_status(:redirect) # Declined tokens redirect for private events
+ end
+
+ it 'handles expired tokens properly' do
+ get better_together.event_path(token_test_event.slug, locale: locale, invitation_token: expired_invitation.token)
+ expect(response).to have_http_status(:ok) # System allows expired token access to view
+ end
+
+ it 'handles non-existent tokens gracefully' do
+ get better_together.event_path(token_test_event.slug, locale: locale, invitation_token: 'non_existent')
+ expect(response).to have_http_status(:redirect) # Non-existent tokens redirect for private events
+ end
+
+ it 'allows access to public events without tokens', :as_user do
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response).to have_http_status(:ok) # Public events are accessible to authenticated users
+ end
+ end
+end
diff --git a/spec/requests/better_together/event_invitation_token_processing_spec.rb b/spec/requests/better_together/event_invitation_token_processing_spec.rb
new file mode 100644
index 000000000..5ec1e3796
--- /dev/null
+++ b/spec/requests/better_together/event_invitation_token_processing_spec.rb
@@ -0,0 +1,263 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Event Invitation Token Processing' do
+ let(:locale) { I18n.default_locale }
+ let!(:platform) do
+ existing = configure_host_platform
+ if existing
+ existing.update!(privacy: 'public') unless existing.privacy == 'public'
+ existing
+ end
+ end
+ let!(:manager_user) { find_or_create_test_user('manager@example.test', 'password12345', :platform_manager) }
+
+ let!(:event) do
+ BetterTogether::Event.create!(
+ name: 'Test Event',
+ starts_at: 1.week.from_now,
+ identifier: SecureRandom.uuid,
+ privacy: 'public',
+ creator: manager_user.person
+ )
+ end
+
+ let!(:invitation) do
+ BetterTogether::EventInvitation.create!(
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'test@example.test',
+ status: 'pending',
+ locale: 'es', # Test locale handling
+ token: SecureRandom.hex(16),
+ valid_from: Time.zone.now,
+ valid_until: 7.days.from_now
+ )
+ end
+
+ describe 'EventsController invitation token processing' do
+ it 'processes invitation_token parameter' do
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+
+ it 'sets locale from invitation when token is processed' do
+ # Make request with invitation token that has Spanish locale
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ # The locale should be set to the invitation's locale (Spanish)
+ expect(I18n.locale.to_s).to eq('es')
+ end
+
+ it 'handles invalid invitation tokens gracefully' do
+ get better_together.event_path(event.slug, locale: locale, invitation_token: 'invalid-token')
+
+ expect(response).to have_http_status(:ok) # Should still show event, just without invitation processing
+ expect(response.body).to include(event.name)
+ end
+
+ it 'handles missing invitation tokens gracefully' do
+ get better_together.event_path(event.slug, locale: locale)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+
+ it 'processes expired invitations correctly' do
+ expired_invitation = BetterTogether::EventInvitation.create!(
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.test',
+ status: 'pending',
+ locale: I18n.default_locale,
+ token: SecureRandom.hex(16),
+ valid_from: 2.days.ago,
+ valid_until: 1.day.ago
+ )
+
+ get better_together.event_path(event.slug, locale: locale, invitation_token: expired_invitation.token)
+
+ expect(response).to have_http_status(:ok) # Should still show event
+ expect(response.body).to include(event.name)
+ end
+ end
+
+ describe 'ApplicationController set_event_invitation method' do
+ it 'supports both token and invitation_token parameters' do
+ # Test with 'token' parameter
+ get better_together.event_path(event.slug, locale: locale, token: invitation.token)
+ expect(response).to have_http_status(:ok)
+
+ # Test with 'invitation_token' parameter
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'stores invitation token in session with expiration' do
+ # We can't directly test session storage in request specs, but we can test
+ # that the functionality works by ensuring subsequent requests work
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'sets invitation locale correctly' do
+ # Test that the locale is set from the invitation
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ expect(I18n.locale.to_s).to eq(invitation.locale)
+ end
+ end
+
+ describe 'valid_event_invitation_token_present? helper' do
+ context 'with valid invitation token in session', :skip_host_setup do
+ it 'returns true for valid tokens' do
+ # Since we can't directly manipulate session in request specs,
+ # we test this indirectly through the platform privacy check
+ platform.update!(privacy: 'private')
+
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok) # Should work due to valid token
+ end
+ end
+
+ context 'with expired invitation token in session' do
+ it 'returns false and cleans up session for expired tokens' do
+ expired_invitation = BetterTogether::EventInvitation.create!(
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.test',
+ status: 'pending',
+ locale: I18n.default_locale,
+ token: SecureRandom.hex(16),
+ valid_from: 2.days.ago,
+ valid_until: 1.day.ago
+ )
+
+ platform.update!(privacy: 'private')
+
+ get better_together.event_path(event.slug, locale: locale, invitation_token: expired_invitation.token)
+ expect(response).to redirect_to(new_user_session_path(locale: locale))
+ end
+ end
+
+ context 'with invalid invitation token in session', :skip_host_setup do
+ it 'returns false for non-existent tokens' do
+ platform.update!(privacy: 'private')
+
+ get better_together.event_path(event.slug, locale: locale, invitation_token: 'non-existent-token')
+ # Invalid tokens on private platforms should render 404
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'invitation URL generation and access' do
+ it 'generates correct invitation URLs' do
+ invitation_url = invitation.url_for_review
+ uri = URI.parse(invitation_url)
+
+ # Token should still be present in query params
+ expect(uri.query).to include("invitation_token=#{invitation.token}")
+
+ # Locale may be embedded in the path (e.g. /es/events/...) depending on routing.
+ # Accept either query param or locale segment in path.
+ if uri.query&.include?('locale=')
+ expect(uri.query).to include("locale=#{invitation.locale}")
+ else
+ expect(uri.path).to match("/#{invitation.locale}/")
+ end
+ expect(invitation_url).to include(event.slug)
+ end
+
+ it 'allows access via generated invitation URLs' do
+ invitation_url = invitation.url_for_review
+ uri = URI.parse(invitation_url)
+
+ # Request using the path+query to preserve any path-based locale segment
+ get "#{uri.path}?#{uri.query}"
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+
+ it 'sets correct locale when accessing via invitation URL' do
+ invitation_url = invitation.url_for_review
+ uri = URI.parse(invitation_url)
+
+ get "#{uri.path}?#{uri.query}"
+ expect(response).to have_http_status(:ok)
+
+ # Locale may be set via path segment; ensure effective locale equals invitation locale
+ expect(I18n.locale.to_s).to eq(invitation.locale)
+ end
+ end
+
+ describe 'multiple invitation tokens' do
+ let!(:other_invitation) do
+ BetterTogether::EventInvitation.create!(
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'other@example.test',
+ status: 'pending',
+ locale: 'fr',
+ token: SecureRandom.hex(16),
+ valid_from: Time.zone.now,
+ valid_until: 7.days.from_now
+ )
+ end
+
+ it 'handles switching between different invitation tokens' do
+ # Access with first invitation
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ # Normalize locale string encoding to avoid encoding mismatches
+ expect(I18n.locale.to_s.force_encoding('UTF-8')).to eq('es')
+
+ # Access with second invitation (different locale)
+ get better_together.event_path(event.slug, locale: locale, invitation_token: other_invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(I18n.locale.to_s).to eq('fr')
+ end
+
+ it 'maintains the most recent invitation token in session' do
+ # Access with first invitation
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+
+ # Access with second invitation
+ get better_together.event_path(event.slug, locale: locale, invitation_token: other_invitation.token)
+ expect(response).to have_http_status(:ok)
+
+ # Subsequent access should use the latest invitation context
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe 'invitation token parameter precedence' do
+ it 'prioritizes invitation_token over token parameter' do
+ other_invitation = BetterTogether::EventInvitation.create!(
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'priority@example.test',
+ status: 'pending',
+ locale: 'fr',
+ token: SecureRandom.hex(16),
+ valid_from: Time.zone.now,
+ valid_until: 7.days.from_now
+ )
+
+ # Pass both parameters, invitation_token should take precedence
+ get better_together.event_path(event.slug, locale: locale,
+ token: invitation.token,
+ invitation_token: other_invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ expect(I18n.locale.to_s).to eq('fr') # Should use other_invitation's locale
+ end
+ end
+end
diff --git a/spec/requests/better_together/event_invitations_spec.rb b/spec/requests/better_together/event_invitations_spec.rb
index d0b4dc5ac..d127884ad 100644
--- a/spec/requests/better_together/event_invitations_spec.rb
+++ b/spec/requests/better_together/event_invitations_spec.rb
@@ -17,7 +17,7 @@
describe 'creating an event invitation' do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a pending invitation and sends notifications' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a pending invitation and sends notifications' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
expect do
post better_together.event_invitations_path(event_id: event.slug, locale: locale),
@@ -29,6 +29,49 @@
expect(invitation.invitable).to eq(event)
expect(invitation.status).to eq('pending')
end
+
+ it 'uses person locale and email when inviting existing user' do # rubocop:todo RSpec/MultipleExpectations
+ # Create a person with Spanish locale
+ invitee = create(:better_together_person, locale: 'es')
+
+ expect do
+ post better_together.event_invitations_path(event_id: event.slug, locale: locale),
+ params: { invitation: { invitee_id: invitee.id } }
+ end.to change(BetterTogether::Invitation, :count).by(1)
+
+ invitation = BetterTogether::Invitation.last
+ expect(invitation.invitee).to eq(invitee)
+ expect(invitation.invitee_email).to eq(invitee.email)
+ expect(invitation.locale).to eq('es') # Should use person's locale, not default
+ end
+ end
+
+ describe 'available people endpoint' do
+ it 'returns people who can be invited, excluding already invited ones' do # rubocop:todo RSpec/MultipleExpectations
+ # Create some people
+ invitable_person = create(:better_together_person, name: 'Available Person')
+ already_invited_person = create(:better_together_person, name: 'Already Invited')
+
+ # Create an invitation for one person
+ create(:better_together_event_invitation,
+ invitable: event,
+ invitee: already_invited_person,
+ inviter: manager_user.person,
+ status: 'pending')
+
+ get better_together.available_people_event_invitations_path(event.slug, locale: locale),
+ params: { search: 'Person' }
+
+ expect(response).to have_http_status(:ok)
+ json_response = JSON.parse(response.body)
+
+ # Should include the available person
+ available_names = json_response.pluck('text')
+ expect(available_names).to include(invitable_person.name)
+
+ # Should NOT include the already invited person
+ expect(available_names).not_to include(already_invited_person.name)
+ end
end
describe 'token edge cases' do
@@ -37,7 +80,7 @@
expect(response).to have_http_status(:not_found)
end
- it 'returns not found for expired token' do # rubocop:todo RSpec/ExampleLength
+ it 'returns not found for expired token' do
invitation = create(:better_together_event_invitation,
invitable: event,
invitee_email: 'guest3@example.test',
@@ -52,7 +95,7 @@
describe 'resend throttling' do
# rubocop:todo RSpec/MultipleExpectations
- it 'does not update last_sent within 15 minutes' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'does not update last_sent within 15 minutes' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
invitation = create(:better_together_event_invitation,
invitable: event,
@@ -69,7 +112,7 @@
describe 'accepting via token' do
# rubocop:todo RSpec/MultipleExpectations
- it 'marks accepted and creates attendance' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'marks accepted and creates attendance' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
invitation = create(:better_together_event_invitation,
invitable: event,
@@ -96,7 +139,7 @@
describe 'declining via token' do
# rubocop:todo RSpec/MultipleExpectations
- it 'marks declined' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'marks declined' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
invitation = create(:better_together_event_invitation,
invitable: event,
diff --git a/spec/requests/better_together/events_controller_spec.rb b/spec/requests/better_together/events_controller_spec.rb
index a5d81e2b2..202f45ba1 100644
--- a/spec/requests/better_together/events_controller_spec.rb
+++ b/spec/requests/better_together/events_controller_spec.rb
@@ -65,7 +65,7 @@
)
end
- context 'as platform manager', :as_platform_manager do # rubocop:todo RSpec/ContextWording
+ context 'as platform manager', :as_platform_manager do
it 'shows attendees tab to organizers' do # rubocop:todo RSpec/MultipleExpectations
get better_together.event_path(event, locale:)
@@ -75,7 +75,7 @@
end
end
- context 'as regular user', :as_user do # rubocop:todo RSpec/ContextWording
+ context 'as regular user', :as_user do
it 'does not show attendees tab to non-organizer' do # rubocop:todo RSpec/MultipleExpectations
get better_together.event_path(event, locale:)
@@ -154,9 +154,9 @@
describe 'creating events with different location types' do
let(:locale) { I18n.default_locale }
- context 'as platform manager', :as_platform_manager do # rubocop:todo RSpec/ContextWording
+ context 'as platform manager', :as_platform_manager do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates an event with a simple (name) location' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates an event with a simple (name) location' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
params = {
event: {
@@ -182,7 +182,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates an event with an Address location' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates an event with an Address location' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
address = create(:better_together_address, privacy: 'public')
@@ -212,7 +212,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates an event with a Building location' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates an event with a Building location' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
manager_user = BetterTogether::User.find_by(email: 'manager@example.test') ||
create(:better_together_user, :confirmed, :platform_manager, email: 'manager@example.test')
@@ -244,7 +244,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a draft event with no location assigned' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a draft event with no location assigned' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
params = {
event: {
diff --git a/spec/requests/better_together/events_datetime_partial_spec.rb b/spec/requests/better_together/events_datetime_partial_spec.rb
index fb16e124d..eda47fd15 100644
--- a/spec/requests/better_together/events_datetime_partial_spec.rb
+++ b/spec/requests/better_together/events_datetime_partial_spec.rb
@@ -7,7 +7,7 @@
describe 'form rendering' do
# rubocop:todo RSpec/MultipleExpectations
- it 'renders the datetime fields partial correctly' do # rubocop:todo RSpec/MultipleExpectations, RSpec/ExampleLength
+ it 'renders the datetime fields partial correctly' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
get better_together.new_event_path(locale: locale, format: :html)
@@ -30,7 +30,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'shows proper labels and hints' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'shows proper labels and hints' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
get better_together.new_event_path(locale: locale)
@@ -45,7 +45,7 @@
describe 'form submission with datetime fields' do
# rubocop:todo RSpec/MultipleExpectations
- it 'processes form data correctly with partial' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'processes form data correctly with partial' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# Get the platform manager user created by automatic test configuration
diff --git a/spec/requests/better_together/invitation_expiry_spec.rb b/spec/requests/better_together/invitation_expiry_spec.rb
index a8e76f41e..990dcee13 100644
--- a/spec/requests/better_together/invitation_expiry_spec.rb
+++ b/spec/requests/better_together/invitation_expiry_spec.rb
@@ -15,7 +15,7 @@
let(:invitation) { create(:better_together_platform_invitation, status: 'pending', locale: I18n.default_locale.to_s) }
# rubocop:todo RSpec/MultipleExpectations
- it 'allows access with a valid invitation token and denies after expiry' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'allows access with a valid invitation token and denies after expiry' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# First request with invitation_code should store token + expiry in session and allow access
get better_together.home_page_path(locale: I18n.default_locale, invitation_code: invitation.token)
diff --git a/spec/requests/better_together/joatu/agreements_spec.rb b/spec/requests/better_together/joatu/agreements_spec.rb
index 8256b1e4b..b97987f2f 100644
--- a/spec/requests/better_together/joatu/agreements_spec.rb
+++ b/spec/requests/better_together/joatu/agreements_spec.rb
@@ -3,7 +3,7 @@
require 'rails_helper'
# rubocop:disable Metrics/BlockLength
-RSpec.describe 'BetterTogether::Joatu::Agreements', :as_user do # rubocop:todo RSpec/MultipleMemoizedHelpers
+RSpec.describe 'BetterTogether::Joatu::Agreements', :as_user do
let(:user) { find_or_create_test_user('user@example.test', 'password12345', :user) }
let(:person) { user.person }
let(:offer) { create(:joatu_offer) }
@@ -11,21 +11,21 @@
let(:valid_attributes) { { offer_id: offer.id, request_id: request_record.id, terms: 'terms', value: 'value' } }
let(:agreement) { create(:joatu_agreement, offer: offer, request: request_record) }
- describe 'routing' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'routing' do
it 'routes to #index' do
get "/#{I18n.locale}/exchange/agreements"
expect(response).to have_http_status(:ok) # or whatever is appropriate
end
end
- describe 'GET /index' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'GET /index' do
it 'returns success' do
get better_together.joatu_agreements_path(locale: I18n.locale)
expect(response).to be_successful
end
end
- describe 'POST /create' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'POST /create' do
it 'creates an agreement' do
expect do
post better_together.joatu_agreements_path(locale: I18n.locale), params: { joatu_agreement: valid_attributes }
@@ -33,16 +33,16 @@
end
end
- describe 'GET /show' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'GET /show' do
it 'returns success' do
get better_together.joatu_agreement_path(agreement, locale: I18n.locale)
expect(response).to be_successful
end
end
- describe 'PATCH /update' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'PATCH /update' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates the agreement' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates the agreement' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
patch better_together.joatu_agreement_path(agreement, locale: I18n.locale),
params: { joatu_agreement: { status: 'accepted' } }
@@ -53,7 +53,7 @@
end
end
- describe 'DELETE /destroy' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'DELETE /destroy' do
it 'destroys the agreement' do
to_delete = create(:joatu_agreement, offer: offer, request: request_record)
expect do
diff --git a/spec/requests/better_together/joatu/matchmaking_spec.rb b/spec/requests/better_together/joatu/matchmaking_spec.rb
index be008ed71..542ccf29f 100644
--- a/spec/requests/better_together/joatu/matchmaking_spec.rb
+++ b/spec/requests/better_together/joatu/matchmaking_spec.rb
@@ -3,7 +3,7 @@
require 'rails_helper'
# rubocop:disable Metrics/BlockLength
-RSpec.describe 'Joatu matchmaking', :as_platform_manager do # rubocop:todo RSpec/MultipleMemoizedHelpers
+RSpec.describe 'Joatu matchmaking', :as_platform_manager do
let(:requestor) { create(:better_together_person) }
let(:offeror) { create(:better_together_person) }
let(:category) { create(:better_together_joatu_category) }
@@ -19,14 +19,14 @@
end
let(:locale) { I18n.default_locale }
- describe 'GET /exchange/requests/:id/matches' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'GET /exchange/requests/:id/matches' do
it 'renders matching offers' do
get "/#{locale}/exchange/requests/#{request_model.id}/matches"
expect(response.body).to include(offer.name)
end
end
- describe 'POST /exchange/agreements' do # rubocop:todo RSpec/MultipleMemoizedHelpers
+ describe 'POST /exchange/agreements' do
it 'creates an agreement and accepts it' do
post "/#{locale}/exchange/agreements", params: { offer_id: offer.id, request_id: request_model.id }
agreement = BetterTogether::Joatu::Agreement.last
diff --git a/spec/requests/better_together/joatu/offers_aggregated_matches_spec.rb b/spec/requests/better_together/joatu/offers_aggregated_matches_spec.rb
index 0eb831960..765c82b5f 100644
--- a/spec/requests/better_together/joatu/offers_aggregated_matches_spec.rb
+++ b/spec/requests/better_together/joatu/offers_aggregated_matches_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe 'Offers aggregated matches', :as_user do
# rubocop:todo RSpec/MultipleExpectations
- it 'shows Potential Matches for my offers with matching requests' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'shows Potential Matches for my offers with matching requests' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# Current authenticated user creating an offer
current_user = BetterTogether::User.find_by(email: 'user@example.test') ||
diff --git a/spec/requests/better_together/joatu/offers_spec.rb b/spec/requests/better_together/joatu/offers_spec.rb
index ebc6f3a4b..4fb5c3864 100644
--- a/spec/requests/better_together/joatu/offers_spec.rb
+++ b/spec/requests/better_together/joatu/offers_spec.rb
@@ -43,7 +43,7 @@
describe 'PATCH /update' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates the offer' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates the offer' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
patch better_together.joatu_offer_path(offer, locale: I18n.locale),
params: { joatu_offer: { status: 'closed' } }
diff --git a/spec/requests/better_together/joatu/requests_aggregated_matches_spec.rb b/spec/requests/better_together/joatu/requests_aggregated_matches_spec.rb
index d57e0523d..45bfa4f0e 100644
--- a/spec/requests/better_together/joatu/requests_aggregated_matches_spec.rb
+++ b/spec/requests/better_together/joatu/requests_aggregated_matches_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe 'Requests aggregated matches', :as_user do
# rubocop:todo RSpec/MultipleExpectations
- it 'shows Potential Matches for my requests with matching offers' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'shows Potential Matches for my requests with matching offers' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# Current authenticated user creating a request
current_user = BetterTogether::User.find_by(email: 'user@example.test') ||
diff --git a/spec/requests/better_together/joatu/requests_spec.rb b/spec/requests/better_together/joatu/requests_spec.rb
index 89f0aa13f..7331d3536 100644
--- a/spec/requests/better_together/joatu/requests_spec.rb
+++ b/spec/requests/better_together/joatu/requests_spec.rb
@@ -46,7 +46,7 @@
describe 'PATCH /update' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates the request' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates the request' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
patch better_together.joatu_request_path(request_record, locale: locale),
params: { joatu_request: { status: 'closed' } }
diff --git a/spec/requests/better_together/joatu/response_links_controller_spec.rb b/spec/requests/better_together/joatu/response_links_controller_spec.rb
index 68ea62011..99dadc127 100644
--- a/spec/requests/better_together/joatu/response_links_controller_spec.rb
+++ b/spec/requests/better_together/joatu/response_links_controller_spec.rb
@@ -9,7 +9,7 @@
let(:request_resource) { create(:better_together_joatu_request) }
# rubocop:todo RSpec/MultipleExpectations
- it 'prevents creating a response when source is closed' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'prevents creating a response when source is closed' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
offer.update!(status: 'closed')
post joatu_response_links_path(locale: I18n.locale), params: { source_type: 'BetterTogether::Joatu::Offer', source_id: offer.id }
@@ -21,7 +21,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a response and marks the source matched when allowed' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a response and marks the source matched when allowed' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
request_resource.update!(status: 'open')
post joatu_response_links_path(locale: I18n.locale), params: { source_type: 'BetterTogether::Joatu::Request', source_id: request_resource.id }
diff --git a/spec/requests/better_together/metrics/link_click_reports_controller_spec.rb b/spec/requests/better_together/metrics/link_click_reports_controller_spec.rb
index 785d2a623..e720dc5d7 100644
--- a/spec/requests/better_together/metrics/link_click_reports_controller_spec.rb
+++ b/spec/requests/better_together/metrics/link_click_reports_controller_spec.rb
@@ -19,7 +19,7 @@
describe 'POST /:locale/.../metrics/link_click_reports' do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a report and redirects with valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a report and redirects with valid params' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.metrics_link_click_reports_path(locale:), params: {
metrics_link_click_report: {
diff --git a/spec/requests/better_together/metrics/link_click_reports_download_spec.rb b/spec/requests/better_together/metrics/link_click_reports_download_spec.rb
index b02c4da2a..89c9d582f 100644
--- a/spec/requests/better_together/metrics/link_click_reports_download_spec.rb
+++ b/spec/requests/better_together/metrics/link_click_reports_download_spec.rb
@@ -5,7 +5,7 @@
RSpec.describe 'BetterTogether::Metrics::LinkClickReportsController download', :as_platform_manager do
let(:locale) { I18n.default_locale }
# rubocop:todo RSpec/MultipleExpectations
- it 'downloads an attached report file' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'downloads an attached report file' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
report = BetterTogether::Metrics::LinkClickReport.create!(file_format: 'csv')
diff --git a/spec/requests/better_together/metrics/page_view_reports_controller_spec.rb b/spec/requests/better_together/metrics/page_view_reports_controller_spec.rb
index 042488c6b..19d2ed4e8 100644
--- a/spec/requests/better_together/metrics/page_view_reports_controller_spec.rb
+++ b/spec/requests/better_together/metrics/page_view_reports_controller_spec.rb
@@ -19,7 +19,7 @@
describe 'POST /:locale/.../metrics/page_view_reports' do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a report and redirects with valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a report and redirects with valid params' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.metrics_page_view_reports_path(locale:), params: {
metrics_page_view_report: {
diff --git a/spec/requests/better_together/metrics/page_view_reports_download_spec.rb b/spec/requests/better_together/metrics/page_view_reports_download_spec.rb
index 84b0eb19c..de3b1bf17 100644
--- a/spec/requests/better_together/metrics/page_view_reports_download_spec.rb
+++ b/spec/requests/better_together/metrics/page_view_reports_download_spec.rb
@@ -5,7 +5,7 @@
RSpec.describe 'BetterTogether::Metrics::PageViewReportsController download', :as_platform_manager do
let(:locale) { I18n.default_locale }
# rubocop:todo RSpec/MultipleExpectations
- it 'downloads an attached report file' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'downloads an attached report file' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
report = BetterTogether::Metrics::PageViewReport.create!(file_format: 'csv')
diff --git a/spec/requests/better_together/metrics/page_views_controller_spec.rb b/spec/requests/better_together/metrics/page_views_controller_spec.rb
index e6cb53ae0..f99f59f31 100644
--- a/spec/requests/better_together/metrics/page_views_controller_spec.rb
+++ b/spec/requests/better_together/metrics/page_views_controller_spec.rb
@@ -6,7 +6,7 @@
let(:locale) { I18n.default_locale }
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a page view with valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a page view with valid params' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
page = create(:better_together_page)
@@ -20,7 +20,7 @@
expect(JSON.parse(response.body)['success']).to be(true)
end
- it 'returns 422 for invalid viewable' do # rubocop:todo RSpec/ExampleLength
+ it 'returns 422 for invalid viewable' do
post better_together.metrics_page_views_path(locale:), params: {
viewable_type: 'NonExistent',
viewable_id: '123',
diff --git a/spec/requests/better_together/metrics/search_queries_controller_spec.rb b/spec/requests/better_together/metrics/search_queries_controller_spec.rb
index 8dd36323c..8f9b1bb10 100644
--- a/spec/requests/better_together/metrics/search_queries_controller_spec.rb
+++ b/spec/requests/better_together/metrics/search_queries_controller_spec.rb
@@ -6,7 +6,7 @@
let(:locale) { I18n.default_locale }
# rubocop:todo RSpec/MultipleExpectations
- it 'tracks a search query with valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'tracks a search query with valid params' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.metrics_search_queries_path(locale:), params: {
query: 'test',
diff --git a/spec/requests/better_together/metrics/shares_controller_spec.rb b/spec/requests/better_together/metrics/shares_controller_spec.rb
index f508d192b..a183a55ff 100644
--- a/spec/requests/better_together/metrics/shares_controller_spec.rb
+++ b/spec/requests/better_together/metrics/shares_controller_spec.rb
@@ -5,7 +5,7 @@
RSpec.describe 'BetterTogether::Metrics::SharesController' do
let(:locale) { I18n.default_locale }
# rubocop:todo RSpec/MultipleExpectations
- it 'tracks a share with valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'tracks a share with valid params' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.metrics_shares_path(locale:), params: {
diff --git a/spec/requests/better_together/metrics_track_share_job_spec.rb b/spec/requests/better_together/metrics_track_share_job_spec.rb
index c53b53799..932140cc7 100644
--- a/spec/requests/better_together/metrics_track_share_job_spec.rb
+++ b/spec/requests/better_together/metrics_track_share_job_spec.rb
@@ -9,7 +9,7 @@
let(:url) { 'https://example.org/somewhere' }
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a share for an allowed shareable type' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a share for an allowed shareable type' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
perform_enqueued_jobs do
expect do
@@ -26,7 +26,7 @@
expect(share.shareable).to eq(page)
end
- it 'rejects disallowed shareable types via resolver (no record created)' do # rubocop:todo RSpec/ExampleLength
+ it 'rejects disallowed shareable types via resolver (no record created)' do
perform_enqueued_jobs do
expect do
post better_together.metrics_shares_path(locale: I18n.default_locale), params: {
diff --git a/spec/requests/better_together/navigation_areas_controller_spec.rb b/spec/requests/better_together/navigation_areas_controller_spec.rb
index abb9ba13b..033554892 100644
--- a/spec/requests/better_together/navigation_areas_controller_spec.rb
+++ b/spec/requests/better_together/navigation_areas_controller_spec.rb
@@ -20,7 +20,7 @@
describe 'POST /:locale/.../navigation_areas' do
# rubocop:todo RSpec/MultipleExpectations
- it 'creates and redirects on valid params, persisting permitted fields' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates and redirects on valid params, persisting permitted fields' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.navigation_areas_path(locale:), params: {
navigation_area: {
@@ -50,7 +50,7 @@
let!(:area) { create(:better_together_navigation_area, protected: false) }
# rubocop:todo RSpec/MultipleExpectations
- it 'updates and redirects on valid params, applying changes' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates and redirects on valid params, applying changes' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
patch better_together.navigation_area_path(locale:, id: area.slug), params: {
navigation_area: { style: 'secondary', visible: false }
diff --git a/spec/requests/better_together/navigation_items_controller_spec.rb b/spec/requests/better_together/navigation_items_controller_spec.rb
index 761a2231d..c984ae5a6 100644
--- a/spec/requests/better_together/navigation_items_controller_spec.rb
+++ b/spec/requests/better_together/navigation_items_controller_spec.rb
@@ -30,7 +30,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'creates a navigation item and redirects (HTML), persisting permitted fields' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'creates a navigation item and redirects (HTML), persisting permitted fields' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
post better_together.navigation_area_navigation_items_path(
locale:,
@@ -71,7 +71,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'updates with valid params then redirects and applies changes' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates with valid params then redirects and applies changes' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
put better_together.navigation_area_navigation_item_path(
locale:,
@@ -86,7 +86,7 @@
expect(item.reload.title(locale:)).to eq('Updated Title')
end
- it 'renders edit on invalid params (422)' do # rubocop:todo RSpec/ExampleLength
+ it 'renders edit on invalid params (422)' do
put better_together.navigation_area_navigation_item_path(
locale:,
navigation_area_id: navigation_area.slug,
@@ -97,7 +97,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'destroys and redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'destroys and redirects' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
delete better_together.navigation_area_navigation_item_path(
locale:,
diff --git a/spec/requests/better_together/people_controller_spec.rb b/spec/requests/better_together/people_controller_spec.rb
index e77f1a2ec..7cec62f6f 100644
--- a/spec/requests/better_together/people_controller_spec.rb
+++ b/spec/requests/better_together/people_controller_spec.rb
@@ -23,7 +23,7 @@
let!(:person) { create(:better_together_person) }
# rubocop:todo RSpec/MultipleExpectations
- it 'updates name and redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates name and redirects' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
patch better_together.person_path(locale:, id: person.slug), params: {
person: { name: 'Updated Name' }
diff --git a/spec/requests/better_together/person_checklist_items_json_spec.rb b/spec/requests/better_together/person_checklist_items_json_spec.rb
index 3d73f353c..7574857f7 100644
--- a/spec/requests/better_together/person_checklist_items_json_spec.rb
+++ b/spec/requests/better_together/person_checklist_items_json_spec.rb
@@ -7,7 +7,7 @@
let(:item) { create(:better_together_checklist_item, checklist: checklist) }
# rubocop:todo RSpec/MultipleExpectations
- it 'accepts JSON POST with headers and returns json' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'accepts JSON POST with headers and returns json' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
url = better_together.create_person_checklist_item_checklist_checklist_item_path(
locale: I18n.default_locale,
diff --git a/spec/requests/better_together/person_checklist_items_spec.rb b/spec/requests/better_together/person_checklist_items_spec.rb
index d3b9dce29..733d1110c 100644
--- a/spec/requests/better_together/person_checklist_items_spec.rb
+++ b/spec/requests/better_together/person_checklist_items_spec.rb
@@ -6,7 +6,7 @@
include Devise::Test::IntegrationHelpers
let(:user) { create(:user) }
- let!(:person) { create(:better_together_person, user: user) } # rubocop:todo RSpec/LetSetup
+ let!(:person) { create(:better_together_person, user: user) }
let(:checklist) { create(:better_together_checklist) }
let(:items) { create_list(:better_together_checklist_item, 3, checklist: checklist) }
@@ -18,7 +18,7 @@
end
# rubocop:todo RSpec/MultipleExpectations
- it 'returns empty record when none exists and can create a completion' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'returns empty record when none exists and can create a completion' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
# rubocop:todo Layout/LineLength
get "/#{I18n.default_locale}/#{BetterTogether.route_scope_path}/checklists/#{checklist.id}/checklist_items/#{items.first.id}/person_checklist_item"
diff --git a/spec/requests/better_together/person_community_memberships_controller_spec.rb b/spec/requests/better_together/person_community_memberships_controller_spec.rb
index 14fc80eb1..9c871d89d 100644
--- a/spec/requests/better_together/person_community_memberships_controller_spec.rb
+++ b/spec/requests/better_together/person_community_memberships_controller_spec.rb
@@ -7,7 +7,6 @@
let(:locale) { I18n.default_locale }
describe 'POST /:locale/.../host/communities/:community_id/person_community_memberships' do
- # rubocop:todo RSpec/ExampleLength
it 'creates a membership and redirects when actor has update_community permission' do
# rubocop:enable RSpec/MultipleExpectations
community = create(:better_together_community)
diff --git a/spec/requests/better_together/platform_privacy_with_event_invitations_spec.rb b/spec/requests/better_together/platform_privacy_with_event_invitations_spec.rb
new file mode 100644
index 000000000..de42a5951
--- /dev/null
+++ b/spec/requests/better_together/platform_privacy_with_event_invitations_spec.rb
@@ -0,0 +1,205 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Platform Privacy with Event Invitations' do
+ include FactoryBot::Syntax::Methods
+
+ let(:locale) { I18n.default_locale }
+ let!(:platform) { configure_host_platform }
+ let!(:manager_user) { find_or_create_test_user('manager@example.test', 'password12345', :platform_manager) }
+ let!(:regular_user) { find_or_create_test_user('user@example.test', 'password12345', :user) }
+
+ let!(:private_event) do
+ create(:better_together_event,
+ name: 'Private Platform Event',
+ starts_at: 1.week.from_now,
+ privacy: 'private',
+ creator: manager_user.person)
+ end
+
+ let!(:public_event) do
+ create(:better_together_event,
+ name: 'Public Event',
+ starts_at: 1.week.from_now,
+ privacy: 'public',
+ creator: manager_user.person)
+ end
+
+ # Default to private event for most tests
+ let!(:event) { private_event }
+
+ let!(:invitation) do
+ create(:better_together_event_invitation,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'external@example.test',
+ status: 'pending',
+ locale: I18n.default_locale)
+ end
+
+ before do
+ # Make platform private to test invitation access
+ platform.update!(privacy: 'private')
+ end
+
+ describe 'accessing private platform via event invitation token' do
+ context 'when platform is private and user is not authenticated' do
+ before do
+ # Explicitly ensure no user is authenticated for this context
+ reset_session if respond_to?(:reset_session)
+ if respond_to?(:logout)
+ begin
+ logout
+ rescue StandardError
+ # Ignore logout errors
+ end
+ end
+ end
+
+ it 'allows access to event via invitation token' do
+ # Direct access to event without token should redirect to login
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response).to redirect_to(new_user_session_path(locale: locale))
+
+ # Access with invitation token should work
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+
+ it 'stores invitation token in session for later use' do
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+
+ # Check that token is stored in session (we can't directly access session in request specs,
+ # but we can verify the functionality by checking subsequent requests work)
+ expect(response).to have_http_status(:ok)
+
+ # Subsequent requests within the same session should work without token
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'redirects to login for expired invitation tokens' do
+ # Create expired invitation using factory
+ expired_invitation = create(:better_together_event_invitation, :expired,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.test')
+
+ get better_together.event_path(event.slug, locale: locale, invitation_token: expired_invitation.token)
+ expect(response).to redirect_to(new_user_session_path(locale: locale))
+ end
+ end
+
+ context 'when platform is private and user is authenticated', :as_user do
+ it 'allows authenticated users to access events normally' do
+ get better_together.event_path(public_event.slug, locale: locale)
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'still processes invitation tokens for authenticated users' do
+ get better_together.event_path(public_event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(public_event.name)
+ end
+ end
+
+ context 'when platform is public' do
+ before do
+ platform.update!(privacy: 'public')
+ end
+
+ it 'allows unauthenticated access to public events regardless of invitation tokens' do
+ get better_together.event_path(public_event.slug, locale: locale)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(public_event.name)
+ end
+
+ it 'still processes invitation tokens on public platforms for private events' do
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+ end
+ end
+
+ describe 'event invitation URL generation' do
+ it 'generates URLs with invitation tokens that link directly to events' do
+ invitation_url = invitation.url_for_review
+ uri = URI.parse(invitation_url)
+
+ expect(invitation_url).to include(event.slug)
+ expect(uri.query).to include("invitation_token=#{invitation.token}")
+
+ # Locale may be included in the path; accept either form
+ if uri.query&.include?('locale=')
+ expect(uri.query).to include("locale=#{invitation.locale}")
+ else
+ expect(uri.path).to match("/#{invitation.locale}/")
+ end
+ end
+
+ it 'generated URLs bypass platform privacy restrictions' do
+ invitation_url = invitation.url_for_review
+ uri = URI.parse(invitation_url)
+ path_with_params = "#{uri.path}?#{uri.query}"
+
+ get path_with_params
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(event.name)
+ end
+ end
+
+ describe 'registration with event invitation tokens' do
+ it 'redirects to event after successful registration via invitation' do
+ # Simulate visiting event with invitation token first
+ get better_together.event_path(event.slug, locale: locale, invitation_token: invitation.token)
+ expect(response).to have_http_status(:ok)
+
+ # Then try to register
+ post user_registration_path(locale: locale), params: {
+ user: {
+ email: invitation.invitee_email,
+ password: 'password12345',
+ password_confirmation: 'password12345',
+ person_attributes: {
+ name: 'New User',
+ identifier: 'newuser'
+ }
+ },
+ privacy_policy_agreement: '1',
+ terms_of_service_agreement: '1',
+ code_of_conduct_agreement: '1'
+ }
+
+ expect(response).to have_http_status(:ok)
+
+ created_user = BetterTogether::User.find_by(email: invitation.invitee_email)
+ created_user.confirm
+
+ login(invitation.invitee_email, 'password12345')
+
+ # Should redirect to the event after successful registration. Compare by slug to avoid locale path differences
+ expect(response.request.fullpath).to include(event.slug)
+ end
+ end
+
+ describe 'invitation token session management' do
+ it 'clears expired invitation tokens from session' do
+ # Set up session with expired invitation using factory
+ expired_invitation = create(:better_together_event_invitation, :expired,
+ invitable: event,
+ inviter: manager_user.person,
+ invitee_email: 'expired@example.test')
+
+ # Try to access with expired token
+ get better_together.event_path(event.slug, locale: locale, invitation_token: expired_invitation.token)
+ expect(response).to redirect_to(new_user_session_path(locale: locale))
+
+ # Subsequent access should also fail (session should be cleaned)
+ get better_together.event_path(event.slug, locale: locale)
+ expect(response).to redirect_to(new_user_session_path(locale: locale))
+ end
+ end
+end
diff --git a/spec/requests/better_together/platforms_controller_spec.rb b/spec/requests/better_together/platforms_controller_spec.rb
index dfda1eca0..b03706d64 100644
--- a/spec/requests/better_together/platforms_controller_spec.rb
+++ b/spec/requests/better_together/platforms_controller_spec.rb
@@ -20,7 +20,7 @@
describe 'PATCH /:locale/.../host/platforms/:id' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates settings and redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates settings and redirects' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
host_platform = BetterTogether::Platform.find_by(host: true)
patch better_together.platform_path(locale:, id: host_platform.slug), params: {
diff --git a/spec/requests/better_together/roles_controller_spec.rb b/spec/requests/better_together/roles_controller_spec.rb
index ab9bc29fa..4a8072486 100644
--- a/spec/requests/better_together/roles_controller_spec.rb
+++ b/spec/requests/better_together/roles_controller_spec.rb
@@ -20,7 +20,7 @@
describe 'PATCH /:locale/.../host/roles/:id' do
# rubocop:todo RSpec/MultipleExpectations
- it 'updates and redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'updates and redirects' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
role = create(:better_together_role, protected: false)
patch better_together.role_path(locale:, id: role.slug), params: {
diff --git a/spec/services/better_together/joatu/matchmaker_spec.rb b/spec/services/better_together/joatu/matchmaker_spec.rb
index 7f3831646..e656c5875 100644
--- a/spec/services/better_together/joatu/matchmaker_spec.rb
+++ b/spec/services/better_together/joatu/matchmaker_spec.rb
@@ -13,8 +13,7 @@ def with_category(record)
end
describe '.match' do
- context 'pair-specific response link exclusion' do # rubocop:todo RSpec/ContextWording
- # rubocop:todo RSpec/ExampleLength
+ context 'pair-specific response link exclusion' do
it 'excludes an offer only when a response link exists for that specific request->offer pair' do
# rubocop:enable RSpec/MultipleExpectations
request = with_category(create(:better_together_joatu_request, creator: creator_a, status: 'open'))
@@ -48,7 +47,7 @@ def with_category(record)
end
end
- context 'target wildcard behavior' do # rubocop:todo RSpec/ContextWording
+ context 'target wildcard behavior' do
let(:target_person) { create(:better_together_person) }
it 'matches when request has target_id and offer has nil (wildcard)' do
diff --git a/spec/services/better_together/joatu/search_filter_spec.rb b/spec/services/better_together/joatu/search_filter_spec.rb
index bd70f7774..a7910ea7f 100644
--- a/spec/services/better_together/joatu/search_filter_spec.rb
+++ b/spec/services/better_together/joatu/search_filter_spec.rb
@@ -22,7 +22,7 @@ def call(params = {}, relation: nil)
expect(out.pluck(:id)).to eq([allowed.id])
end
- it 'filters by category ids via types_filter[]' do # rubocop:todo RSpec/ExampleLength
+ it 'filters by category ids via types_filter[]' do
cat1 = create(:better_together_joatu_category, name: 'Alpha')
cat2 = create(:better_together_joatu_category, name: 'Beta')
@@ -59,7 +59,7 @@ def call(params = {}, relation: nil)
end
# rubocop:todo RSpec/MultipleExpectations
- it 'filters by status=open' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'filters by status=open' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
open = create(factory)
closed = create(factory)
@@ -79,7 +79,7 @@ def call(params = {}, relation: nil)
expect(out.last).to eq(newer)
end
- it 'applies per_page when pagination is available' do # rubocop:todo RSpec/ExampleLength
+ it 'applies per_page when pagination is available' do
create_list(factory, 12)
out = call({ per_page: '10' })
diff --git a/spec/support/automatic_test_configuration.rb b/spec/support/automatic_test_configuration.rb
index 50a2564ca..5a2c2c61a 100644
--- a/spec/support/automatic_test_configuration.rb
+++ b/spec/support/automatic_test_configuration.rb
@@ -240,9 +240,10 @@ def find_or_create_test_user(email, password, role_type = :user)
platform = BetterTogether::Platform.first
role = BetterTogether::Role.find_by(identifier: 'platform_manager')
if platform && role
- BetterTogether::PlatformMembership.create!(
+ # Use PersonPlatformMembership model which links people to platforms
+ BetterTogether::PersonPlatformMembership.create!(
member: user.person,
- platform: platform,
+ joinable: platform,
role: role
)
end
@@ -257,9 +258,43 @@ def ensure_clean_session
# Session cleanup below + Warden reset is sufficient
reset_session if respond_to?(:reset_session)
+ # For request specs, also clear session directly if available
+ begin
+ session.clear if respond_to?(:session) && session.respond_to?(:clear)
+ rescue StandardError => e
+ # Session may not be available in all contexts
+ Rails.logger.debug "Session clear failed (may be expected): #{e.message}"
+ end
+
+ # Explicitly clear invitation-related session keys if session is available
+ begin
+ if respond_to?(:session) && session.respond_to?(:[]=)
+ session[:event_invitation_token] = nil
+ session[:event_invitation_expires_at] = nil
+ session[:platform_invitation_token] = nil
+ session[:platform_invitation_expires_at] = nil
+ session[:locale] = nil
+ end
+ rescue StandardError => e
+ # Session may not be available in all contexts
+ Rails.logger.debug "Session key cleanup failed (may be expected): #{e.message}"
+ end
+
# Clear any Warden authentication data
@request&.env&.delete('warden') if respond_to?(:request) && defined?(@request)
+ # Force logout for all spec types to ensure clean authentication state
+ # But avoid HTTP logout for Example Automatic Configuration tests to prevent response object creation
+ current_example_description = RSpec.current_example&.example_group&.description || ''
+ if respond_to?(:logout) && !current_example_description.include?('Example Automatic Configuration')
+ begin
+ logout
+ rescue StandardError => e
+ # Ignore logout errors as session may already be clean
+ Rails.logger.debug "Authentication cleanup failed (may be expected): #{e.message}"
+ end
+ end
+
# Clear per-thread authentication marker so new examples can authenticate
Thread.current[:__bt_authenticated_description] = nil
end
diff --git a/spec/support/setup_wizard_helpers.rb b/spec/support/setup_wizard_helpers.rb
index 73073476a..9778652cb 100644
--- a/spec/support/setup_wizard_helpers.rb
+++ b/spec/support/setup_wizard_helpers.rb
@@ -8,7 +8,7 @@
# - call skip_host_setup! inside an example/before block to mark the current example
# - any spec file under spec/features/setup_wizard will have the metadata applied automatically
-RSpec.shared_context 'skip_host_setup', :skip_host_setup do # rubocop:todo RSpec/ContextWording
+RSpec.shared_context 'skip_host_setup', :skip_host_setup do
# metadata-only context; AutomaticTestConfiguration will check for :skip_host_setup
end
diff --git a/spec/support/test_seed_helpers.rb b/spec/support/test_seed_helpers.rb
new file mode 100644
index 000000000..42871b82d
--- /dev/null
+++ b/spec/support/test_seed_helpers.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Ensure commonly referenced test users exist for request specs.
+RSpec.configure do |config|
+ config.before(:suite) do
+ manager_email = 'manager@example.test'
+ user_email = 'user@example.test'
+
+ unless BetterTogether::User.find_by(email: manager_email)
+ person = BetterTogether::Person.create!(name: 'Manager Person')
+ BetterTogether::User.create!(email: manager_email, password: 'testpassword12', person: person)
+ end
+
+ unless BetterTogether::User.find_by(email: user_email)
+ person = BetterTogether::Person.create!(name: 'Test User')
+ BetterTogether::User.create!(email: user_email, password: 'testpassword12', person: person)
+ end
+ end
+end
diff --git a/spec/views/shared/resource_toolbar.html.erb_spec.rb b/spec/views/shared/resource_toolbar.html.erb_spec.rb
index afcff2590..00a9e0f34 100644
--- a/spec/views/shared/resource_toolbar.html.erb_spec.rb
+++ b/spec/views/shared/resource_toolbar.html.erb_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe 'shared/resource_toolbar' do
# rubocop:todo RSpec/MultipleExpectations
- it 'renders provided action buttons' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'renders provided action buttons' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
render partial: 'shared/resource_toolbar', locals: {
edit_path: '/edit',
@@ -29,7 +29,7 @@
expect(rendered).not_to include(t('globals.delete'))
end
- it 'renders additional content from block in extra section' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations
+ it 'renders additional content from block in extra section' do # rubocop:todo RSpec/MultipleExpectations
render inline: <<~ERB
<%= render 'shared/resource_toolbar' do %>
Custom Action