diff --git a/app/controllers/better_together/application_controller.rb b/app/controllers/better_together/application_controller.rb index 1f07a89dd..0a1364641 100644 --- a/app/controllers/better_together/application_controller.rb +++ b/app/controllers/better_together/application_controller.rb @@ -118,14 +118,25 @@ def check_platform_privacy end def valid_platform_invitation_token_present? - token = session[:platform_invitation_token] - return false unless token.present? + # Check platform invitation tokens + platform_token = session[:platform_invitation_token] + if platform_token.present? && (session[:platform_invitation_expires_at].blank? || Time.current <= session[:platform_invitation_expires_at]) && ::BetterTogether::PlatformInvitation.pending.exists?(token: platform_token) + return true + end - if session[:platform_invitation_expires_at].present? && Time.current > session[:platform_invitation_expires_at] - return false + # Check community invitation tokens + community_token = session[:community_invitation_token] + if community_token.present? && (session[:community_invitation_expires_at].blank? || Time.current <= session[:community_invitation_expires_at]) && ::BetterTogether::CommunityInvitation.pending.not_expired.exists?(token: community_token) + return true + end + + # Check event invitation tokens + event_token = session[:event_invitation_token] + if event_token.present? && (session[:event_invitation_expires_at].blank? || Time.current <= session[:event_invitation_expires_at]) && ::BetterTogether::EventInvitation.pending.not_expired.exists?(token: event_token) + return true end - ::BetterTogether::PlatformInvitation.pending.exists?(token: token) + false end # (Joatu-specific notification helpers are defined in BetterTogether::Joatu::Controller) diff --git a/app/controllers/better_together/communities/invitations_controller.rb b/app/controllers/better_together/communities/invitations_controller.rb new file mode 100644 index 000000000..c914f9216 --- /dev/null +++ b/app/controllers/better_together/communities/invitations_controller.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module BetterTogether + module Communities + class InvitationsController < ApplicationController # rubocop:todo Style/Documentation, Metrics/ClassLength + before_action :set_community + before_action :set_invitation, only: %i[destroy resend] + 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 + notify_invitee(@invitation) + respond_success(@invitation, :created) + else + respond_error(@invitation) + end + end + + def destroy + authorize @invitation + invitation_dom_id = helpers.dom_id(@invitation) + @invitation.destroy + + respond_to do |format| + format.html { redirect_to @community, 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 + authorize @invitation + notify_invitee(@invitation) + 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_community + @community = BetterTogether::Community.friendly.find(params[:community_id]) + rescue StandardError + render_not_found + end + + def set_invitation + @invitation = BetterTogether::CommunityInvitation.find(params[:id]) + end + + def invitation_params + params.require(:invitation).permit(:invitee_id, :invitee_email, :valid_from, :valid_until, :locale, :role_id) + end + + def build_invitation + invitation = BetterTogether::CommunityInvitation.new(invitation_params) + invitation.invitable = @community + 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 community + BetterTogether::CommunityInvitation + .where(invitable: @community, 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 + # Also exclude people who are already community members + existing_member_ids = @community.person_community_memberships.pluck(:member_id) + excluded_ids = (invited_ids + existing_member_ids).uniq + + policy_scope(BetterTogether::Person) + .left_joins(:user, contact_detail: :email_addresses) + .where.not(id: excluded_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") + true + else + false + end + end + + def send_notification_to_user(invitation) + # Send notification to existing user through the notification system + BetterTogether::CommunityInvitationNotifier.with(record: invitation.invitable, + invitation:).deliver_later(invitation.invitee) + invitation.update_column(:last_sent, Time.zone.now) + end + + def send_email_invitation(invitation) + # Send email directly to external email address (bypassing notification system) + BetterTogether::CommunityInvitationsMailer.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('community_invitations_table_body', + partial: 'better_together/communities/invitation_row', + collection: @community.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) + respond_to do |format| + format.html { redirect_to @community, notice: t('flash.generic.queued', resource: t('resources.invitation')) } + format.turbo_stream { render_success_turbo_stream(status) } + format.json { render json: { id: invitation.id }, status: } + end + end + + def respond_error(invitation) + respond_to do |format| + format.html { redirect_to @community, alert: invitation.errors.full_messages.to_sentence } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.update('form_errors', partial: 'layouts/better_together/errors', locals: { object: invitation }) # rubocop:disable Layout/LineLength + ], status: :unprocessable_entity + end + format.json { render json: { errors: invitation.errors.full_messages }, status: :unprocessable_entity } + end + end + end + end +end diff --git a/app/controllers/better_together/communities_controller.rb b/app/controllers/better_together/communities_controller.rb index 4e5ec9926..f76477947 100644 --- a/app/controllers/better_together/communities_controller.rb +++ b/app/controllers/better_together/communities_controller.rb @@ -2,6 +2,14 @@ module BetterTogether class CommunitiesController < FriendlyResourceController # rubocop:todo Style/Documentation + include InvitationTokenAuthorization + include NotificationReadable + + # Prepend resource instance setting for privacy check + prepend_before_action :set_resource_instance, only: %i[show edit update destroy] + prepend_before_action :set_community_for_privacy_check, only: [:show] + prepend_before_action :process_community_invitation_token, only: %i[show] + before_action :set_model_instance, only: %i[show edit update destroy] before_action :authorize_community, only: %i[show edit update destroy] after_action :verify_authorized, except: :index @@ -13,7 +21,12 @@ def index end # GET /communities/1 - def show; end + def show + # Check for valid invitation if accessing via invitation token + @current_invitation = find_invitation_by_token + + mark_match_notifications_read_for(resource_instance) + end # GET /communities/new def new @@ -109,7 +122,172 @@ def resource_class end def resource_collection + # Set invitation token for policy scope + invitation_token = params[:invitation_token] || session[:community_invitation_token] + self.current_invitation_token = invitation_token + resource_class.with_translations 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[:community_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 + + def process_community_invitation_token + invitation_token = params[:invitation_token] || session[:community_invitation_token] + return unless invitation_token.present? + + # Find and validate the invitation + invitation = BetterTogether::CommunityInvitation.pending.not_expired.find_by(token: invitation_token) + + if invitation + # Set invitation token for authorization + self.current_invitation_token = invitation_token + + # Store invitation token in session for platform privacy bypass + session[:community_invitation_token] = invitation_token + session[:community_invitation_expires_at] = invitation.valid_until if invitation.valid_until.present? + + # Set locale from invitation if available + I18n.locale = invitation.locale if invitation.locale.present? + else + # Clear invalid token from session + session.delete(:community_invitation_token) + session.delete(:community_invitation_expires_at) + end + end + + def extract_invitation_token + params[:invitation_token].presence || params[:token].presence || current_invitation_token + end + + def find_valid_invitation(token) + if @community + BetterTogether::CommunityInvitation.pending.not_expired.find_by(token: token, invitable: @community) + else + BetterTogether::CommunityInvitation.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[:community_invitation_token] = invitation.token + session[:community_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 + + # Override privacy check to handle community-specific invitation tokens. + 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[:community_invitation_token].presence + end + + def token_and_params_present?(token) + token.present? && params[:id].present? + end + + def find_any_invitation_by_token(token) + ::BetterTogether::CommunityInvitation.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::CommunityInvitation.pending.not_expired.find_by(token: token) + return render_not_found_for_mismatched_invitation unless invitation&.invitable.present? + + community = load_community_safely + return false unless community # Return false to fall back to super in check_platform_privacy + return render_not_found unless invitation_matches_community?(invitation, community) + + store_invitation_and_grant_access(invitation) + end + + def render_not_found_for_mismatched_invitation + render_not_found + end + + def load_community_safely + @community || resource_class.friendly.find(params[:id]) + rescue ActiveRecord::RecordNotFound + nil + end + + def invitation_matches_community?(invitation, community) + invitation.invitable.id == community.id + end + + def store_invitation_and_grant_access(invitation) + session[:community_invitation_token] = invitation.token + session[:community_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 + true # Return true to indicate successful processing + end + + def set_community_for_privacy_check + @community = @resource if @resource.is_a?(BetterTogether::Community) + end end end diff --git a/app/controllers/better_together/invitations_controller.rb b/app/controllers/better_together/invitations_controller.rb index d2ce26304..6fd69c49c 100644 --- a/app/controllers/better_together/invitations_controller.rb +++ b/app/controllers/better_together/invitations_controller.rb @@ -8,6 +8,7 @@ class InvitationsController < ApplicationController # rubocop:todo Style/Documen def show @event = @invitation.invitable if @invitation.is_a?(BetterTogether::EventInvitation) + @community = @invitation.invitable if @invitation.is_a?(BetterTogether::CommunityInvitation) render :show end @@ -49,10 +50,13 @@ def ensure_authenticated! 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 + if @invitation.is_a?(BetterTogether::EventInvitation) + session[:event_invitation_token] = @invitation.token + session[:event_invitation_expires_at] = 24.hours.from_now + elsif @invitation.is_a?(BetterTogether::CommunityInvitation) + session[:community_invitation_token] = @invitation.token + session[:community_invitation_expires_at] = 24.hours.from_now + end end def determine_auth_redirect_path diff --git a/app/controllers/better_together/users/registrations_controller.rb b/app/controllers/better_together/users/registrations_controller.rb index faf140b26..e626bead5 100644 --- a/app/controllers/better_together/users/registrations_controller.rb +++ b/app/controllers/better_together/users/registrations_controller.rb @@ -10,6 +10,7 @@ class RegistrationsController < ::Devise::RegistrationsController # rubocop:todo before_action :configure_permitted_parameters before_action :set_required_agreements, only: %i[new create] before_action :set_event_invitation_from_session, only: %i[new create] + before_action :set_community_invitation_from_session, only: %i[new create] before_action :configure_account_update_params, only: [:update] # PUT /resource @@ -144,6 +145,9 @@ def handle_captcha_validation_failure(resource) end def after_sign_up_path_for(resource) + # Redirect to community if signed up via community invitation + return better_together.community_path(@community_invitation.invitable) if @community_invitation&.invitable + # Redirect to event if signed up via event invitation return better_together.event_path(@event_invitation.event) if @event_invitation&.event @@ -167,9 +171,25 @@ def set_event_invitation_from_session nil if @event_invitation end + def set_community_invitation_from_session + return unless session[:community_invitation_token].present? + + # Check if session token is still valid + return if session[:community_invitation_expires_at].present? && + Time.current > session[:community_invitation_expires_at] + + @community_invitation = ::BetterTogether::CommunityInvitation.pending.not_expired + .find_by(token: session[:community_invitation_token]) + + nil if @community_invitation + end + def determine_community_role return @platform_invitation.community_role if @platform_invitation + # For community invitations, use the invitation's role + return @community_invitation.role if @community_invitation && @community_invitation.role.present? + # For event invitations, use the event creator's community return @event_invitation.role if @event_invitation && @event_invitation.role.present? @@ -187,6 +207,13 @@ def setup_user_from_invitations(user) # Pre-fill email from platform invitation user.email = @platform_invitation.invitee_email if @platform_invitation && user.email.empty? + # Pre-fill email from community invitation + if @community_invitation + user.email = @community_invitation.invitee_email if @community_invitation && user.email.empty? + user.person = @community_invitation.invitee if @community_invitation.invitee.present? + return + end + return unless @event_invitation # Pre-fill email from event invitation @@ -195,7 +222,7 @@ def setup_user_from_invitations(user) end def handle_user_creation(user) - return unless event_invitation_person_updated?(user) + return unless invitation_person_updated?(user) # Reload user to ensure all nested attributes and associations are properly loaded user.reload @@ -205,10 +232,33 @@ def handle_user_creation(user) setup_community_membership(user, person) handle_platform_invitation(user) + handle_community_invitation(user) handle_event_invitation(user) create_agreement_participants(person) end + def invitation_person_updated?(user) + # Check community invitation first + if @community_invitation&.invitee.present? + return true if user.person.update(person_params) + + Rails.logger.error "Failed to update person for community invitation: #{user.person.errors.full_messages}" + return false + + end + + # Check event invitation + if @event_invitation&.invitee.present? + return true if user.person.update(person_params) + + Rails.logger.error "Failed to update person for event invitation: #{user.person.errors.full_messages}" + return false + + end + + true + end + def event_invitation_person_updated?(user) return true unless @event_invitation&.invitee.present? @@ -319,6 +369,17 @@ def handle_event_invitation(user) session.delete(:event_invitation_expires_at) end + def handle_community_invitation(user) + return unless @community_invitation + + @community_invitation.update!(invitee: user.person) + @community_invitation.accept!(invitee_person: user.person) + + # Clear session data + session.delete(:community_invitation_token) + session.delete(:community_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/mailers/better_together/community_invitations_mailer.rb b/app/mailers/better_together/community_invitations_mailer.rb new file mode 100644 index 000000000..36fde193f --- /dev/null +++ b/app/mailers/better_together/community_invitations_mailer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module BetterTogether + class CommunityInvitationsMailer < ApplicationMailer # rubocop:todo Style/Documentation + # 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 + return if to_email.blank? + + send_invitation_email(invitation, to_email) + end + + private + + def setup_invitation_data(invitation) + @invitation = invitation + @community = 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.community_invitations_mailer.invite.subject', + community_name: @community&.name, + default: 'You are invited to join %s')) + end + end + end +end diff --git a/app/models/better_together/community.rb b/app/models/better_together/community.rb index 890655424..f3872957c 100644 --- a/app/models/better_together/community.rb +++ b/app/models/better_together/community.rb @@ -21,6 +21,12 @@ class Community < ApplicationRecord has_many :calendars, class_name: 'BetterTogether::Calendar', dependent: :destroy has_one :default_calendar, -> { where(name: 'Default') }, class_name: 'BetterTogether::Calendar' + # Community invitations + has_many :invitations, -> { where(invitable_type: 'BetterTogether::Community') }, + as: :invitable, + class_name: 'BetterTogether::CommunityInvitation', + dependent: :destroy + joinable joinable_type: 'community', member_type: 'person' diff --git a/app/models/better_together/community_invitation.rb b/app/models/better_together/community_invitation.rb new file mode 100644 index 000000000..5b0800652 --- /dev/null +++ b/app/models/better_together/community_invitation.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module BetterTogether + # Invitation for Communities using the polymorphic invitations table + class CommunityInvitation < Invitation + STATUS_VALUES = { + pending: 'pending', + accepted: 'accepted', + declined: 'declined' + }.freeze + + enum :status, STATUS_VALUES, prefix: :status + + validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) } + validate :invitee_presence + validate :invitee_uniqueness_for_community + + # 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 community) + def community + invitable + end + + def after_accept!(invitee_person: nil) + person = invitee_person || resolve_invitee_person + return unless person && community + + # Create community membership with the specified role (default to community_member) + ensure_community_membership!(person) + end + + def accept!(invitee_person: nil) + self.status = STATUS_VALUES[:accepted] + save! + after_accept!(invitee_person:) + end + + def decline! + self.status = STATUS_VALUES[:declined] + save! + end + + def url_for_review + BetterTogether::Engine.routes.url_helpers.community_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 + + def ensure_token_present + return if token.present? + + self.token = self.class.generate_unique_secure_token + end + + def resolve_invitee_person + return invitee if invitee.is_a?(BetterTogether::Person) + + nil + end + + def invitee_presence + return unless invitee.blank? && self[:invitee_email].to_s.strip.blank? + + errors.add(:base, 'Either invitee or invitee_email must be present') + end + + def invitee_uniqueness_for_community + return unless community + + check_duplicate_person_invitation + check_duplicate_email_invitation + end + + def ensure_community_membership!(person) + return unless community + + # Use the role specified in the invitation, or default to community_member + target_role = role || BetterTogether::Role.find_by(identifier: 'community_member') + + # Create community membership for the invitee + community.person_community_memberships.find_or_create_by!( + member: person, + role: target_role + ) + end + + def check_duplicate_person_invitation + return unless invitee.present? + + existing = community.invitations.where(invitee:, status: %w[pending accepted]) + .where.not(id: id) + errors.add(:invitee, 'has already been invited to this community') if existing.exists? + end + + def check_duplicate_email_invitation + return unless invitee_email.present? + + existing = community.invitations.where(invitee_email:, status: %w[pending accepted]) + .where.not(id: id) + errors.add(:invitee_email, 'has already been invited to this community') if existing.exists? + end + end +end \ No newline at end of file diff --git a/app/notifiers/better_together/community_invitation_notifier.rb b/app/notifiers/better_together/community_invitation_notifier.rb new file mode 100644 index 000000000..aba76ad12 --- /dev/null +++ b/app/notifiers/better_together/community_invitation_notifier.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module BetterTogether + class CommunityInvitationNotifier < ApplicationNotifier # rubocop:todo Style/Documentation + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications + deliver_by :email, mailer: 'BetterTogether::CommunityInvitationsMailer', method: :invite, params: :email_params, + queue: :mailers + + required_param :invitation + + notification_methods do + delegate :title, :body, :invitation, :invitable, to: :community + end + + def invitation = params[:invitation] + def invitable = params[:invitable] || invitation&.invitable + + def locale + params[:invitation]&.locale || I18n.locale || I18n.default_locale + end + + def title + I18n.with_locale(locale) do + I18n.t('better_together.notifications.community_invitation.title', + community_name: invitable&.name, default: 'You have been invited to join a community') + end + end + + def body + I18n.with_locale(locale) do + I18n.t('better_together.notifications.community_invitation.body', + community_name: invitable&.name, default: 'Invitation to join %s') + end + end + + def build_message(_notification) + # Pass the invitable (community) as the notification url object so views can + # link to the community record (consistent with other notifiers that pass + # domain objects like agreement/request). + { title:, body:, url: invitation.url_for_review } + end + + def email_params(_notification) + # Include the invitation and the invitable (community) 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/policies/better_together/community_invitation_policy.rb b/app/policies/better_together/community_invitation_policy.rb new file mode 100644 index 000000000..0ec178ab9 --- /dev/null +++ b/app/policies/better_together/community_invitation_policy.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module BetterTogether + class CommunityInvitationPolicy < ApplicationPolicy # rubocop:todo Style/Documentation + def create? + return false unless user.present? + + # Community organizers (coordinators, facilitators) and platform managers can invite people + return true if allowed_on_community? + + permitted_to?('manage_platform') + end + + def destroy? + user.present? && record.status == 'pending' && allowed_on_community? + end + + def resend? + user.present? && record.status == 'pending' && allowed_on_community? + end + + class Scope < Scope # rubocop:todo Style/Documentation + def resolve + return scope.none unless user.present? + return scope.all if permitted_to?('manage_platform') + + # Users see invitations for communities they can manage + community_invitations_scope + end + + private + + def community_invitations_scope + scope.joins(:invitable) + .where(better_together_invitations: { invitable_type: 'BetterTogether::Community' }) + .where(manageable_communities_condition) + end + + def manageable_communities_condition + manageable_community_ids = user.person&.member_communities&.joins(:person_community_memberships) + &.where( + better_together_person_community_memberships: { + member_id: user.person.id + } + ) + &.joins('JOIN better_together_roles ON better_together_roles.id = better_together_person_community_memberships.role_id') # rubocop:disable Layout/LineLength + &.where( + 'better_together_roles.identifier IN (?)', + %w[community_coordinator community_facilitator] + )&.pluck(:id) || [] + + return '1=0' if manageable_community_ids.empty? # No access if no manageable communities + + "better_together_communities.id IN (#{manageable_community_ids.join(',')})" + end + end + + private + + def allowed_on_community? + community = record.invitable + return false unless community.is_a?(BetterTogether::Community) + + # Platform managers may act across communities + return true if permitted_to?('manage_platform') + + cp = BetterTogether::CommunityPolicy.new(user, community) + # Community organizers (coordinators, facilitators) can invite members + cp.update? + end + end +end diff --git a/app/policies/better_together/community_policy.rb b/app/policies/better_together/community_policy.rb index 4098ae3c2..cf123a373 100644 --- a/app/policies/better_together/community_policy.rb +++ b/app/policies/better_together/community_policy.rb @@ -7,7 +7,10 @@ def index? end def show? - record.privacy_public? || (user.present? && permitted_to?('read_community')) + record.privacy_public? || + (user.present? && permitted_to?('read_community')) || + invitation? || + valid_invitation_token? end def create? @@ -37,6 +40,28 @@ def destroy? )) end + def invitation? + return false unless agent.present? + + # Check if the current person has an invitation to this community + BetterTogether::CommunityInvitation.exists?( + invitable: record, + invitee: agent + ) + end + + # Check if there's a valid invitation token for this community + def valid_invitation_token? + return false unless invitation_token.present? + + invitation = BetterTogether::CommunityInvitation.find_by( + token: invitation_token, + invitable: record + ) + + invitation.present? && invitation.status_pending? + end + class Scope < Scope # rubocop:todo Style/Documentation def resolve scope.order(updated_at: :desc).where(permitted_query) @@ -67,6 +92,17 @@ def permitted_query # rubocop:todo Metrics/AbcSize, Metrics/MethodLength ) end + # Add logic for invitation token access + if invitation_token.present? + invitation_table = ::BetterTogether::CommunityInvitation.arel_table + community_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(communities_table[:id].in(community_ids_with_valid_invitations)) + end + query end # rubocop:enable Metrics/MethodLength diff --git a/app/views/better_together/communities/_invitation_review.html.erb b/app/views/better_together/communities/_invitation_review.html.erb new file mode 100644 index 000000000..94bf30a63 --- /dev/null +++ b/app/views/better_together/communities/_invitation_review.html.erb @@ -0,0 +1,37 @@ +<%# Invitation review partial - shows status and accept/decline actions %> +<%# Expected local: invitation (BetterTogether::CommunityInvitation) %> +<% invitation ||= local_assigns[:invitation] || @current_invitation %> + +
+
<%= t('better_together.invitations.review', default: 'Invitation') %>
+ + <%# Status badge - map statuses to Bootstrap badge classes %> + <% status = invitation&.status || 'pending' %> + <% actionable = (status == 'pending') %> + <% badge_class = case status + when 'accepted' then 'badge bg-success' + when 'declined' then 'badge bg-secondary' + when 'pending' then 'badge bg-warning text-dark' + else 'badge bg-light text-dark' + end %> + +
+ <%= t("better_together.invitations.status.#{status}", default: status.humanize) %> +
+ + <% if actionable %> +
+ <%# Accept button: actionable only when invitation is pending %> + <%= button_to better_together.accept_invitation_path(invitation.token), { method: :post, class: 'btn btn-success' } do %> + + <%= t('better_together.invitations.accept', default: 'Accept') %> + <% end %> + + <%# Decline button: actionable only when invitation is pending %> + <%= button_to better_together.decline_invitation_path(invitation.token), { method: :post, class: 'btn btn-outline-secondary' } do %> + + <%= t('better_together.invitations.decline', default: 'Decline') %> + <% end %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/better_together/communities/_invitation_row.html.erb b/app/views/better_together/communities/_invitation_row.html.erb new file mode 100644 index 000000000..46a04d5f4 --- /dev/null +++ b/app/views/better_together/communities/_invitation_row.html.erb @@ -0,0 +1,82 @@ + + + <% if invitation_row.for_existing_user? %> +
+ <%= profile_image_tag(invitation_row.invitee, size: 32, class: 'rounded-circle me-2 invitee') %> +
+
<%= invitation_row.invitee.name %>
+ @<%= invitation_row.invitee.identifier %> +
+
+ <% else %> +
+
+ +
+
+
<%= invitation_row.invitee_email %>
+ <%= t('better_together.invitations.external_user', default: 'External user') %> +
+
+ <% end %> + + + <% if invitation_row.for_existing_user? %> + + + <%= t('better_together.invitations.type.person', default: 'Member') %> + + <% else %> + + + <%= t('better_together.invitations.type.email', default: 'Email') %> + + <% end %> + + + <% case invitation_row.status %> + <% when 'pending' %> + + + <%= t('globals.pending', default: 'Pending') %> + + <% when 'accepted' %> + + + <%= t('globals.accepted', default: 'Accepted') %> + + <% when 'declined' %> + + + <%= t('globals.declined', default: 'Declined') %> + + <% end %> + + + <% if invitation_row.last_sent %> + <%= time_ago_in_words(invitation_row.last_sent) %> <%= t('globals.ago', default: 'ago') %> + <% else %> + <%= t('globals.not_sent', default: 'Not sent') %> + <% end %> + + + <% if policy(invitation_row).resend? %> + <%= button_to t('globals.resend', default: 'Resend'), + better_together.resend_community_invitation_path(@community, invitation_row), + method: :put, + class: 'btn btn-outline-secondary btn-sm me-2', + data: { turbo: true } %> + <% end %> + <% if policy(invitation_row).destroy? %> + <%= button_to t('globals.remove', default: 'Remove'), + better_together.community_invitation_path(@community, invitation_row), + method: :delete, + class: 'btn btn-outline-danger btn-sm', + data: { + turbo: true, + confirm: t('better_together.invitations.confirm_remove', + default: 'Are you sure you want to remove this invitation?') + } %> + <% end %> + + \ No newline at end of file diff --git a/app/views/better_together/communities/_invitations_panel.html.erb b/app/views/better_together/communities/_invitations_panel.html.erb new file mode 100644 index 000000000..5c5f759f2 --- /dev/null +++ b/app/views/better_together/communities/_invitations_panel.html.erb @@ -0,0 +1,99 @@ +<% invitation = BetterTogether::CommunityInvitation.new(invitable: @community, inviter: current_person) %> +<%# Only show the invitation UI to users permitted to manage the community %> +<% if policy(invitation).create? %> +
+
+ <%= t('better_together.invitations.community_panel.title', default: 'Invite People') %> +
+
+
+ + + + + +
+ +
+ <%= form_with url: better_together.community_invitations_path(community_id: @community.slug), method: :post, data: { turbo: true }, id: 'invite_person_form' do |f| %> +
+
+ <%= f.label :invitee_id, t('better_together.invitations.select_person', default: 'Select Person'), class: 'form-label' %> + <%= f.select :invitee_id, [], + { prompt: t('better_together.invitations.search_people', default: 'Search for people...') }, + { + class: 'form-select', + name: 'invitation[invitee_id]', + data: { + controller: 'better-together--slim-select', + 'better-together--slim-select-options-value': { + ajax: { + url: better_together.available_people_community_invitations_path(@community.slug, format: :json) + }, + settings: { + searchPlaceholder: t('better_together.invitations.search_people', default: 'Search for people...'), + searchHighlight: true, + closeOnSelect: true + } + }.to_json + } + } %> +
+
+ <%= f.label :role_id, t('better_together.invitations.select_role', default: 'Role'), class: 'form-label' %> + <%= f.collection_select :role_id, + BetterTogether::Role.where(resource_type: 'BetterTogether::Community').i18n.order(:name), + :id, :name, + { prompt: t('better_together.invitations.default_role', default: 'Default Role') }, + { class: 'form-select', name: 'invitation[role_id]' } %> +
+
+ <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %> +
+
+ <% end %> +
+ + +
+ <%= form_with url: better_together.community_invitations_path(community_id: @community.slug), method: :post, data: { turbo: true }, id: 'invite_email_form' do |f| %> +
+
+ <%= f.label :invitee_email, t('better_together.invitations.invitee_email', default: 'Email'), class: 'form-label' %> + <%= f.email_field :invitee_email, name: 'invitation[invitee_email]', class: 'form-control', required: true %> +
+
+ <%= f.label :locale, t('globals.locale', default: 'Locale'), class: 'form-label' %> + <%= language_select_field(form: f, field_name: :locale, selected_locale: I18n.locale, html_options: { name: 'invitation[locale]', class: 'form-select' }) %> +
+
+ <%= f.label :role_id, t('better_together.invitations.select_role', default: 'Role'), class: 'form-label' %> + <%= f.collection_select :role_id, + BetterTogether::Role.where(resource_type: 'BetterTogether::Community').i18n.order(:name), + :id, :name, + { prompt: t('better_together.invitations.default_role', default: 'Default Role') }, + { class: 'form-select', name: 'invitation[role_id]' } %> +
+
+ <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %> +
+
+ <% end %> +
+
+
+
+<% end %> + +<%= render 'better_together/communities/invitations_table' %> \ No newline at end of file diff --git a/app/views/better_together/communities/_invitations_table.html.erb b/app/views/better_together/communities/_invitations_table.html.erb new file mode 100644 index 000000000..c5241c415 --- /dev/null +++ b/app/views/better_together/communities/_invitations_table.html.erb @@ -0,0 +1,21 @@ +<% invitations = BetterTogether::CommunityInvitation.where(invitable: @community).order(:status, :created_at) %> +<% if invitations.any? %> +
+
<%= t('better_together.invitations.title', default: 'Community Invitations') %>
+
+ + + + + + + + + + + + <%= render partial: 'better_together/communities/invitation_row', collection: invitations %> + +
<%= t('better_together.invitations.invitee', default: 'Invitee') %><%= t('better_together.invitations.invitee_type', default: 'Invitee Type') %><%= t('globals.status', default: 'Status') %><%= t('globals.sent', default: 'Sent') %>
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/communities/show.html.erb b/app/views/better_together/communities/show.html.erb index c8b0a6d38..605d3128f 100644 --- a/app/views/better_together/communities/show.html.erb +++ b/app/views/better_together/communities/show.html.erb @@ -41,6 +41,10 @@ + <% if @current_invitation %> + <%= render 'better_together/communities/invitation_review', invitation: @current_invitation %> + <% end %> +
@@ -78,6 +82,8 @@
+ <%= render 'better_together/communities/invitations_panel' %> + <% if policy(BetterTogether::PersonCommunityMembership).create? %>
diff --git a/app/views/better_together/community_invitations_mailer/invite.html.erb b/app/views/better_together/community_invitations_mailer/invite.html.erb new file mode 100644 index 000000000..558810014 --- /dev/null +++ b/app/views/better_together/community_invitations_mailer/invite.html.erb @@ -0,0 +1,60 @@ +
+

<%= t('.greeting', default: 'Hello!') %>

+ +

+ <%= t('.invited_html', default: 'You have been invited to join the community %{community_name}.', community_name: @community&.name).html_safe %> +

+ + <% if @invitation.inviter %> +

+ <%= t('.invited_by_html', default: 'You were invited by %{inviter_name}.', inviter_name: @invitation.inviter.name).html_safe %> +

+ <% end %> + +
+

<%= t('.community_details', default: 'Community Details') %>

+ +

+ <%= t('.community_name', default: 'Community') %>: + <%= @community.name %> +

+ + <% if @community.description_html.present? %> +
+ <%= t('.about', default: 'About') %>:
+ <%= simple_format(@community.description_html.to_plain_text) %> +
+ <% elsif @community.description.present? %> +
+ <%= t('.about', default: 'About') %>:
+ <%= simple_format(@community.description) %> +
+ <% end %> + + <% if @invitation.role %> +

+ <%= t('.your_role', default: 'Your Role') %>: + <%= @invitation.role.name %> +

+ <% end %> +
+ + + +
+

+ <%= t('.need_account_html', default: "You'll need to create an account to accept this invitation and join the community.").html_safe %> +

+
+ +
+ +

+ <%= t('better_together.mailer.footer.no_reply', default: 'This is an automated message. Please do not reply to this email.') %> +

+
\ No newline at end of file diff --git a/app/views/layouts/better_together/_sign_in.html.erb b/app/views/layouts/better_together/_sign_in.html.erb index d7930faac..710c11668 100644 --- a/app/views/layouts/better_together/_sign_in.html.erb +++ b/app/views/layouts/better_together/_sign_in.html.erb @@ -3,7 +3,7 @@ <% if valid_platform_invitation_token_present? %> <%= link_to new_user_registration_path(locale: I18n.locale, invitation_code: session[:platform_invitation_token]), class: "nav-link d-flex align-items-center gap-2" do %> - <%= t('navbar.accept_invitation') if current_invitation.registers_user? %> + <%= t('navbar.accept_invitation') if current_invitation&.registers_user? %> <% expires_at_unix = invitation_token_expires_at %>