diff --git a/app/assets/javascripts/application/organizations_filter.js b/app/assets/javascripts/application/organizations_filter.js new file mode 100644 index 000000000..0ae1c372b --- /dev/null +++ b/app/assets/javascripts/application/organizations_filter.js @@ -0,0 +1,28 @@ +$(function() { + $(document).on('change', '.organization-checkbox', function() { + var searchParams = new URLSearchParams(window.location.search); + var cat = searchParams.get('cat'); + var q = searchParams.get('q'); + var tag = searchParams.get('tag'); + + var form = $(this).closest('form'); + + if (cat) { + if (form.find('input[name="cat"]').length === 0) { + form.append(''); + } + } + + if (q) { + form.find('input[name="q"]').val(q); + } + + if (tag) { + if (form.find('input[name="tag"]').length === 0) { + form.append(''); + } + } + + form.submit(); + }); + }); diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index 232ba52e1..e3895f6ea 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -6,7 +6,9 @@ def model def show super - member = @offer.user.members.find_by(organization: current_organization) - @destination_account = member.account if member + if @offer.user + member = @offer.user.members.find_by(organization: current_organization) + @destination_account = member.account if member + end end end diff --git a/app/controllers/organization_alliances_controller.rb b/app/controllers/organization_alliances_controller.rb new file mode 100644 index 000000000..27c340c2a --- /dev/null +++ b/app/controllers/organization_alliances_controller.rb @@ -0,0 +1,79 @@ +class OrganizationAlliancesController < ApplicationController + before_action :authenticate_user! + before_action :member_should_exist_and_be_active + before_action :authorize_admin + before_action :find_alliance, only: [:update, :destroy] + + def index + @status = params[:status] || "pending" + + @alliances = case @status + when "pending" + current_organization.pending_sent_alliances.includes(:source_organization, :target_organization) + + current_organization.pending_received_alliances.includes(:source_organization, :target_organization) + when "accepted" + current_organization.accepted_alliances.includes(:source_organization, :target_organization) + when "rejected" + current_organization.rejected_alliances.includes(:source_organization, :target_organization) + else + [] + end + end + + def create + @alliance = OrganizationAlliance.new( + source_organization: current_organization, + target_organization_id: params[:organization_alliance][:target_organization_id], + status: "pending" + ) + + if @alliance.save + flash[:notice] = t("organization_alliances.created") + else + flash[:error] = @alliance.errors.full_messages.to_sentence + end + + redirect_back fallback_location: organizations_path + end + + def update + authorize @alliance + + if @alliance.update(status: params[:status]) + flash[:notice] = t("organization_alliances.updated") + else + flash[:error] = @alliance.errors.full_messages.to_sentence + end + + redirect_to organization_alliances_path + end + + def destroy + authorize @alliance + + if @alliance.destroy + flash[:notice] = t("organization_alliances.destroyed") + else + flash[:error] = t("organization_alliances.error_destroying") + end + + redirect_to organization_alliances_path + end + + private + + def find_alliance + @alliance = OrganizationAlliance.find(params[:id]) + end + + def authorize_admin + unless current_user.manages?(current_organization) + flash[:error] = t("organization_alliances.not_authorized") + redirect_to root_path + end + end + + def alliance_params + params.require(:organization_alliance).permit(:target_organization_id) + end +end diff --git a/app/controllers/organization_transfers_controller.rb b/app/controllers/organization_transfers_controller.rb new file mode 100644 index 000000000..f2a18e844 --- /dev/null +++ b/app/controllers/organization_transfers_controller.rb @@ -0,0 +1,58 @@ +class OrganizationTransfersController < ApplicationController + before_action :authenticate_user! + before_action :check_manager_role + before_action :set_organizations, only: [:new] + before_action :validate_alliance, only: [:new, :create] + + def new + @transfer = Transfer.new + end + + def create + @transfer = Transfer.new(transfer_params) + @transfer.source = current_organization.account + @transfer.destination = destination_organization.account + @transfer.post = nil + persister = ::Persister::TransferPersister.new(@transfer) + + if persister.save + redirect_to organization_path(destination_organization), + notice: t('organizations.transfers.create.success') + else + set_organizations + flash.now[:error] = t('organizations.transfers.create.error', error: @transfer.errors.full_messages.to_sentence) + render :new + end + end + + private + + def transfer_params + params.require(:transfer).permit(:amount, :hours, :minutes, :reason) + end + + def check_manager_role + unless current_user.manages?(current_organization) + redirect_to root_path, alert: t('organization_alliances.not_authorized') + end + end + + def set_organizations + @source_organization = current_organization + @destination_organization = destination_organization + end + + def destination_organization + @destination_organization ||= Organization.find(params[:destination_organization_id]) + rescue ActiveRecord::RecordNotFound + redirect_to organizations_path, alert: t('application.tips.user_not_found') + end + + def validate_alliance + alliance = current_organization.alliance_with(destination_organization) + unless alliance && alliance.accepted? + redirect_to organizations_path, + alert: t('transfers.cross_bank.no_alliance') + end + end +end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 5a8e3616b..ac9723c85 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -6,10 +6,14 @@ class PostsController < ApplicationController def index context = model.active.of_active_members - if current_organization.present? - context = context.where( - organization_id: current_organization.id - ) + if current_user.present? && current_organization.present? + if params[:show_allied].present? + allied_org_ids = current_organization.allied_organizations.pluck(:id) + org_ids = [current_organization.id] + allied_org_ids + context = context.by_organizations(org_ids) + elsif !params[:org].present? + context = context.by_organization(current_organization.id) + end end posts = apply_scopes(context) @@ -71,6 +75,19 @@ def destroy redirect_to send("#{resources}_path") if post.update!(active: false) end + def contact + @post = Post.find(params[:id]) + + if current_user && current_organization != @post.organization && current_user.active?(current_organization) + OrganizationNotifier.contact_request(@post, current_user, current_organization).deliver_later + flash[:notice] = t('posts.contact.success') + else + flash[:error] = t('posts.contact.error') + end + + redirect_to @post + end + private def resource diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index e65506566..b10222dab 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -3,6 +3,13 @@ class TransfersController < ApplicationController def create @source = find_source + + if params[:cross_bank] == "true" && params[:post_id].present? + post = Post.find(params[:post_id]) + create_cross_bank_transfer(post) + return + end + @account = Account.find(transfer_params[:destination]) transfer = Transfer.new( @@ -23,16 +30,21 @@ def new current_organization, current_user, params[:offer], - params[:destination_account_id] + params[:destination_account_id], + params[:cross_bank] == "true" ) + @cross_bank = params[:cross_bank] == "true" + @offer = transfer_factory.offer + render( :new, locals: { accountable: transfer_factory.accountable, transfer: transfer_factory.build_transfer, offer: transfer_factory.offer, - sources: transfer_factory.transfer_sources + sources: transfer_factory.transfer_sources, + cross_bank: @cross_bank } ) end @@ -49,6 +61,86 @@ def delete_reason private + def create_cross_bank_transfer(post) + transfer_factory = TransferFactory.new( + current_organization, + current_user, + post.id, + nil, + true + ) + + destination_organization = transfer_factory.destination_organization + + unless current_organization.alliance_with(destination_organization)&.accepted? + redirect_back fallback_location: post, + alert: t('transfers.cross_bank.no_alliance') + return + end + + @persisters = [] + + user_account = current_user.members.find_by(organization: current_organization).account + org_account = current_organization.account + + if user_account.id != org_account.id + user_to_org_transfer = Transfer.new( + source: user_account, + destination: org_account, + amount: transfer_params[:amount], + reason: post.description, + post: post + ) + @persisters << ::Persister::TransferPersister.new(user_to_org_transfer) + end + + org_to_org_transfer = Transfer.new( + source: org_account, + destination: destination_organization.account, + amount: transfer_params[:amount], + reason: post.description, + post: post + ) + @persisters << ::Persister::TransferPersister.new(org_to_org_transfer) + + member = post.user.members.find_by(organization: destination_organization) + if member && member.account + org_to_user_transfer = Transfer.new( + source: destination_organization.account, + destination: member.account, + amount: transfer_params[:amount], + reason: post.description, + post: post + ) + @persisters << ::Persister::TransferPersister.new(org_to_user_transfer) + else + redirect_back fallback_location: post, alert: t('transfers.cross_bank.error') + return + end + + if persisters_saved? + redirect_to post, notice: t('transfers.cross_bank.success') + else + @persisters.each do |persister| + persister.transfer.destroy if persister.transfer.persisted? + end + redirect_back fallback_location: post, alert: @error_messages || t('transfers.cross_bank.error') + end + end + + def persisters_saved? + @error_messages = [] + + @persisters.each do |persister| + unless persister.save + @error_messages << persister.transfer.errors.full_messages.to_sentence + return false + end + end + + true + end + def find_source if admin? Account.find(transfer_params[:source]) diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb new file mode 100644 index 000000000..47438672b --- /dev/null +++ b/app/helpers/organizations_helper.rb @@ -0,0 +1,17 @@ +module OrganizationsHelper + def filterable_organizations + Organization.all.order(:name) + end + + def allied_organizations + return [] unless current_organization + + allied_org_ids = current_organization.accepted_alliances.map do |alliance| + alliance.source_organization_id == current_organization.id ? + alliance.target_organization_id : alliance.source_organization_id + end + + organizations = Organization.where(id: allied_org_ids + [current_organization.id]) + organizations.order(:name) + end +end diff --git a/app/helpers/transfers_helper.rb b/app/helpers/transfers_helper.rb index 7e6cf20f0..93d27f0c0 100644 --- a/app/helpers/transfers_helper.rb +++ b/app/helpers/transfers_helper.rb @@ -22,4 +22,15 @@ def accounts_from_movements(transfer, with_links: false) end end end + + def is_bank_to_bank_transfer?(transfer) + return false unless transfer + + source_account = transfer.movements.find_by('amount < 0')&.account + destination_account = transfer.movements.find_by('amount > 0')&.account + + source_account&.accountable_type == 'Organization' && + destination_account&.accountable_type == 'Organization' && + transfer.post.nil? + end end diff --git a/app/mailers/organization_notifier.rb b/app/mailers/organization_notifier.rb index 2625b98e3..d2420731d 100644 --- a/app/mailers/organization_notifier.rb +++ b/app/mailers/organization_notifier.rb @@ -56,4 +56,18 @@ def no_membership_warning(user) ) end end + + def contact_request(post, requester, requester_organization) + @post = post + @requester = requester + @requester_organization = requester_organization + @offerer = post.user + + I18n.with_locale(@offerer.locale) do + mail( + to: @offerer.email, + subject: t('organization_notifier.contact_request.subject', post: @post.title) + ) + end + end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 90847ee3b..2d15c9ee5 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -24,6 +24,8 @@ class Organization < ApplicationRecord has_many :inquiries has_many :documents, as: :documentable, dependent: :destroy has_many :petitions, dependent: :delete_all + has_many :source_alliances, class_name: "OrganizationAlliance", foreign_key: "source_organization_id", dependent: :destroy + has_many :target_alliances, class_name: "OrganizationAlliance", foreign_key: "target_organization_id", dependent: :destroy validates :name, presence: true, uniqueness: true @@ -61,6 +63,33 @@ def display_id account.accountable_id end + def alliance_with(organization) + source_alliances.find_by(target_organization: organization) || + target_alliances.find_by(source_organization: organization) + end + + def pending_sent_alliances + source_alliances.pending + end + + def pending_received_alliances + target_alliances.pending + end + + def accepted_alliances + source_alliances.accepted.or(target_alliances.accepted) + end + + def rejected_alliances + source_alliances.rejected.or(target_alliances.rejected) + end + + def allied_organizations + source_org_ids = source_alliances.accepted.pluck(:target_organization_id) + target_org_ids = target_alliances.accepted.pluck(:source_organization_id) + Organization.where(id: source_org_ids + target_org_ids) + end + def ensure_reg_number_seq! update_column(:reg_number_seq, members.maximum(:member_uid)) end diff --git a/app/models/organization_alliance.rb b/app/models/organization_alliance.rb new file mode 100644 index 000000000..6d242358f --- /dev/null +++ b/app/models/organization_alliance.rb @@ -0,0 +1,23 @@ +class OrganizationAlliance < ApplicationRecord + belongs_to :source_organization, class_name: "Organization" + belongs_to :target_organization, class_name: "Organization" + + enum status: { pending: 0, accepted: 1, rejected: 2 } + + validates :source_organization_id, presence: true + validates :target_organization_id, presence: true + validates :target_organization_id, uniqueness: { scope: :source_organization_id } + validate :cannot_ally_with_self + + scope :pending, -> { where(status: "pending") } + scope :accepted, -> { where(status: "accepted") } + scope :rejected, -> { where(status: "rejected") } + + private + + def cannot_ally_with_self + if source_organization_id == target_organization_id + errors.add(:base, "Cannot create an alliance with yourself") + end + end +end diff --git a/app/models/post.rb b/app/models/post.rb index 199755bf3..bf5867500 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -30,6 +30,9 @@ class Post < ApplicationRecord scope :by_organization, ->(org) { where(organization_id: org) if org } + scope :by_organizations, ->(org_ids) { + where(organization_id: org_ids) if org_ids.present? + } scope :of_active_members, -> { with_member.where("members.active") } diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 41e4db8ca..24c994020 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -35,6 +35,8 @@ def destination_id destination.respond_to?(:id) ? destination.id : destination end + private + def different_source_and_destination return unless source == destination errors.add(:base, :same_account) diff --git a/app/models/transfer_factory.rb b/app/models/transfer_factory.rb index a093299cd..36464c0f4 100644 --- a/app/models/transfer_factory.rb +++ b/app/models/transfer_factory.rb @@ -1,27 +1,37 @@ class TransferFactory - def initialize(current_organization, current_user, offer_id, destination_account_id) + def initialize(current_organization, current_user, offer_id, destination_account_id = nil, cross_bank = false) @current_organization = current_organization @current_user = current_user @offer_id = offer_id @destination_account_id = destination_account_id + @cross_bank = cross_bank end - # Returns the offer that is the subject of the transfer - # - # @return [Maybe] def offer - current_organization.offers.find_by_id(offer_id) + @offer ||= Offer.find_by_id(offer_id) if offer_id.present? end - # Returns a new instance of Transfer with the data provided - # - # @return [Transfer] def build_transfer transfer = Transfer.new(source: source, destination: destination_account.id) - transfer.post = offer unless for_organization? + + transfer.post = offer if (cross_bank && offer && offer.organization != current_organization) || + (offer && !for_organization?) + transfer end + def source_organization + current_organization + end + + def destination_organization + offer&.organization + end + + def final_destination_user + offer&.user + end + def transfer_sources if admin? [current_organization.account] + @@ -32,49 +42,36 @@ def transfer_sources end def accountable - @accountable ||= destination_account.accountable + @accountable ||= destination_account.try(:accountable) end private attr_reader :current_organization, :current_user, :offer_id, - :destination_account_id + :destination_account_id, :cross_bank - # Returns the id of the account that acts as source of the transfer. - # Either the account of the organization or the account of the current user. - # - # @return [Maybe] def source - organization = if accountable.is_a?(Organization) - accountable - else - current_organization - end - - current_user.members.find_by(organization: organization).account.id + current_user.members.find_by(organization: current_organization).account.id end - # Checks whether the destination account is an organization - # - # @return [Boolean] def for_organization? - destination_account.accountable.class == Organization + destination_account&.accountable.is_a?(Organization) end def admin? current_user.try :manages?, current_organization end - # TODO: this method implements authorization by scoping the destination - # account in all the accounts of the current organization. If the specified - # destination account does not belong to it, the request will simply faily. - # - # Returns the account the time will be transfered to - # - # @return [Account] + def destination_organization_account + offer.organization.account + end + def destination_account - @destination_account ||= current_organization - .all_accounts - .find(destination_account_id) + @destination_account ||= if destination_account_id + current_organization.all_accounts.find(destination_account_id) + elsif offer + member = offer.user.members.find_by(organization: offer.organization) + member.account if member + end end end diff --git a/app/policies/organization_alliance_policy.rb b/app/policies/organization_alliance_policy.rb new file mode 100644 index 000000000..53c3af049 --- /dev/null +++ b/app/policies/organization_alliance_policy.rb @@ -0,0 +1,11 @@ +class OrganizationAlliancePolicy < ApplicationPolicy + def update? + alliance = record + user.manages?(alliance.source_organization) || user.manages?(alliance.target_organization) + end + + def destroy? + alliance = record + user.manages?(alliance.source_organization) || user.manages?(alliance.target_organization) + end +end diff --git a/app/views/application/menus/_organization_listings_menu.html.erb b/app/views/application/menus/_organization_listings_menu.html.erb index aeb701c57..20f246ca5 100644 --- a/app/views/application/menus/_organization_listings_menu.html.erb +++ b/app/views/application/menus/_organization_listings_menu.html.erb @@ -16,6 +16,12 @@ <%= t('petitions.applications') %> <% end %> +
  • + <%= link_to organization_alliances_path, class: "dropdown-item" do %> + <%= glyph :globe %> + <%= t "application.navbar.organization_alliances" %> + <% end %> +
  • <%= link_to offers_path(org: current_organization), class: "dropdown-item" do %> <%= glyph :link %> diff --git a/app/views/inquiries/index.html.erb b/app/views/inquiries/index.html.erb index 198dbc77d..a72f20fc8 100644 --- a/app/views/inquiries/index.html.erb +++ b/app/views/inquiries/index.html.erb @@ -11,7 +11,7 @@ <%= render "shared/post_filters", base_path: inquiries_path %>
    - <% if current_user && current_organization && !params[:org] %> + <% if current_user && current_organization %>
    +<% else %> +
    + <% if current_user && current_user.active?(current_organization) %> + <%= link_to contact_inquiry_path(@inquiry), + method: :post, + data: { confirm: t('posts.show.contact_confirmation') }, + class: "btn btn-primary" do %> + <%= glyph :envelope %> + <%= t 'posts.show.request_contact' %> + <% end %> + <% end %> +
    <% end %> -<%= render "shared/post", post: @inquiry %> +<%= render "shared/post", post: @inquiry %> \ No newline at end of file diff --git a/app/views/offers/index.html.erb b/app/views/offers/index.html.erb index 69fd3d4d7..8347898b9 100644 --- a/app/views/offers/index.html.erb +++ b/app/views/offers/index.html.erb @@ -11,7 +11,7 @@ <%= render "shared/post_filters", base_path: offers_path %>
    - <% if current_user && current_organization && !params[:org] %> + <% if current_user && current_organization %>
    +<% else %> +
    + <% if current_user && current_user.active?(current_organization) %> + <% if current_organization != @offer.organization %> + <%= link_to t('posts.show.request_contact'), + contact_post_path(@offer), + method: :post, + data: { confirm: t('posts.show.contact_confirmation') }, + class: "btn btn-primary me-2" %> + <% end %> + <%= link_to new_transfer_path(id: @offer.user.id, offer: @offer.id, cross_bank: true), + class: "btn btn-success" do %> + <%= glyph :time %> + <%= t ".give_time_for" %> + <% end %> + <% end %> +
    <% end %> -<%= render "shared/post", post: @offer %> +<%= render "shared/post", post: @offer %> \ No newline at end of file diff --git a/app/views/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb new file mode 100644 index 000000000..2747be620 --- /dev/null +++ b/app/views/organization_alliances/index.html.erb @@ -0,0 +1,102 @@ +

    <%= t('organization_alliances.title') %>

    + +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + <% if @status != 'rejected' %> + + <% end %> + + + + <% @alliances.each do |alliance| %> + <% is_sender = (alliance.source_organization_id == current_organization.id) %> + <% other_org = is_sender ? alliance.target_organization : alliance.source_organization %> + + + + + + <% if @status == 'pending' %> + + <% elsif @status == 'accepted' %> + + <% end %> + + <% end %> + +
    <%= t('organization_alliances.organization') %><%= t('organization_alliances.city') %><%= t('organization_alliances.members') %><%= t('organization_alliances.type') %><%= t('organization_alliances.actions') %>
    <%= link_to other_org.name, other_org %><%= other_org.city %><%= other_org.members.count %> + <% if is_sender %> + <%= t('organization_alliances.sent') %> + <% else %> + <%= t('organization_alliances.received') %> + <% end %> + + <% if is_sender %> + <%= link_to t('organization_alliances.cancel_request'), + organization_alliance_path(alliance), + method: :delete, + class: 'btn btn-danger', + data: { confirm: t('organization_alliances.confirm_cancel') } %> + <% else %> +
    + <%= link_to t('organization_alliances.accept'), + organization_alliance_path(alliance, status: 'accepted'), + method: :put, + class: 'btn btn-primary' %> + <%= link_to t('organization_alliances.reject'), + organization_alliance_path(alliance, status: 'rejected'), + method: :put, + class: 'btn btn-danger' %> +
    + <% end %> +
    + <%= link_to t('organization_alliances.end_alliance'), + organization_alliance_path(alliance), + method: :delete, + class: 'btn btn-danger', + data: { confirm: t('organization_alliances.confirm_end') } %> +
    +
    +
    +
    +
    diff --git a/app/views/organization_notifier/contact_request.html.erb b/app/views/organization_notifier/contact_request.html.erb new file mode 100644 index 000000000..b10fed862 --- /dev/null +++ b/app/views/organization_notifier/contact_request.html.erb @@ -0,0 +1,26 @@ +<%= t('organization_notifier.contact_request.greeting', name: @offerer.username) %> + +<%= t('organization_notifier.contact_request.message', + requester: @requester.username, + organization: @requester_organization.name, + post: @post.title) %> + +<%= t('organization_notifier.contact_request.requester_info') %>: + + + <%= t('activerecord.attributes.user.username') %>: <%= @requester.username %> + <% if @requester.has_valid_email? %> + <%= t('activerecord.attributes.user.email') %>: <%= @requester.email %> + <% end %> + <% phones = [@requester.phone, @requester.alt_phone].select(&:present?) %> + <% if phones.present? %> + <%= t('users.show.phone', count: phones.size) %>: + <% phones.each_with_index do |phone, index| %> + <%= " — " if index != 0 %> + <%= phone %> + <% end %> + + <% end %> + + +<%= t('organization_notifier.contact_request.closing') %> \ No newline at end of file diff --git a/app/views/organization_transfers/new.html.erb b/app/views/organization_transfers/new.html.erb new file mode 100644 index 000000000..946a2209e --- /dev/null +++ b/app/views/organization_transfers/new.html.erb @@ -0,0 +1,33 @@ +

    + <%= t 'organizations.transfers.new.title' %> +

    +
    + <%= t 'organizations.transfers.new.description', + source_organization: @source_organization.name, + destination_organization: @destination_organization.name %> +
    +<%= simple_form_for @transfer, url: organization_to_organization_transfers_path(destination_organization_id: @destination_organization.id) do |f| %> +
    + <%= f.input :hours, + as: :integer, + input_html: { + min: 0, + "data-rule-either-hours-minutes-informed" => "true" + } %> + <%= f.input :minutes, + as: :integer, + input_html: { + min: 0, + max: 59, + step: 15, + "data-rule-either-hours-minutes-informed" => "true", + "data-rule-range" => "[0,59]" + } %> + <%= f.input :amount, as: :hidden %> + <%= f.input :reason %> +
    +
    + <%= f.button :submit, t('organizations.transfers.new.submit'), class: "btn btn-primary" %> + +
    +<% end %> diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb new file mode 100644 index 000000000..6be3d44e1 --- /dev/null +++ b/app/views/organizations/_alliance_button.html.erb @@ -0,0 +1,16 @@ +<% if current_user&.manages?(current_organization) && organization != current_organization %> + <% alliance = current_organization.alliance_with(organization) %> + <% if alliance.nil? %> + <%= link_to t('organization_alliances.request_alliance'), + organization_alliances_path(organization_alliance: { target_organization_id: organization.id }), + method: :post, + class: 'btn btn-secondary', + aria: { label: t('organization_alliances.request_alliance_for', org: organization.name) } %> + <% elsif alliance.pending? %> + <%= t('organization_alliances.pending') %> + <% elsif alliance.accepted? %> + <%= t('organization_alliances.active') %> + <% elsif alliance.rejected? %> + <%= t('organization_alliances.rejected') %> + <% end %> +<% end %> diff --git a/app/views/organizations/_organizations_row.html.erb b/app/views/organizations/_organizations_row.html.erb index f68caa55f..30b5a6c5d 100644 --- a/app/views/organizations/_organizations_row.html.erb +++ b/app/views/organizations/_organizations_row.html.erb @@ -7,4 +7,9 @@ <%= render "organizations/petition_button", organization: org %> + <% if current_user&.manages?(current_organization) %> + + <%= render "organizations/alliance_button", organization: org %> + + <% end %> diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb index a6954fa58..f750148d6 100644 --- a/app/views/organizations/index.html.erb +++ b/app/views/organizations/index.html.erb @@ -25,7 +25,10 @@ <%= t '.neighborhood' %> <%= t '.web' %> <%= t '.member_count' %> - + <%= t '.membership' %> + <% if current_user&.manages?(current_organization) %> + <%= t '.alliance' %> + <% end %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 5bfda7a5b..9daf1d4d1 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -100,8 +100,23 @@ <% end %>
  • <% end %> + <% if current_user&.manages?(current_organization) && + @organization != current_organization && + current_organization.alliance_with(@organization)&.accepted? %> + + <% end %> - <%= render "organizations/petition_button", organization: @organization %> +
    + <%= render "organizations/petition_button", organization: @organization %> +
    + <%= render "organizations/alliance_button", organization: @organization %> +
    +
    diff --git a/app/views/shared/_movements.html.erb b/app/views/shared/_movements.html.erb index 62fbd588f..12959fb37 100644 --- a/app/views/shared/_movements.html.erb +++ b/app/views/shared/_movements.html.erb @@ -20,25 +20,29 @@ <%= l mv.created_at.to_date, format: :long %> - <% mv.other_side.account.tap do |account| %> - <% if account.accountable.present? %> - <% if account.accountable_type == "Organization" %> - <%= link_to account, - organization_path(account.accountable) %> - <% elsif account.accountable.active %> - <%= link_to account.accountable.display_name_with_uid, - user_path(account.accountable.user) %> - <% else %> - <%= t("users.show.inactive_user") %> - <% end %> + <% + display_account = mv.other_side.account + %> + + <% if display_account.accountable.present? %> + <% if display_account.accountable_type == "Organization" %> + <%= link_to display_account, + organization_path(display_account.accountable) %> + <% elsif display_account.accountable.active %> + <%= link_to display_account.accountable.display_name_with_uid, + user_path(display_account.accountable.user) %> <% else %> - <%= t("users.show.deleted_user") %> + <%= t("users.show.inactive_user") %> <% end %> + <% else %> + <%= t("users.show.deleted_user") %> <% end %> <% if mv.transfer&.post&.active? %> <%= link_to mv.transfer.post, offer_path(mv.transfer.post) %> + <% elsif is_bank_to_bank_transfer?(mv.transfer) %> + <%= t("organizations.transfers.bank_transfer") %> <% else %> <%= mv.transfer.post %> <% end %> diff --git a/app/views/shared/_post.html.erb b/app/views/shared/_post.html.erb index c079f84bf..e73ff26ba 100644 --- a/app/views/shared/_post.html.erb +++ b/app/views/shared/_post.html.erb @@ -69,15 +69,23 @@ -<% if !current_user || post.organization != current_organization || !current_user.active?(current_organization) %> -
    + +<% if current_user && post.organization != current_organization && current_user.active?(current_organization) %> +
    + <%= t 'posts.show.contact_info_hidden', + type: post.class.model_name.human, + organization: post.organization.name %> +
    +<% elsif !current_user || post.organization != current_organization || !current_user.active?(current_organization) %> +
    <%= t 'posts.show.info', type: post.class.model_name.human, organization: post.organization.name %>
    <% end %> + <% unless current_user %> -
    +
    <%= link_to t("layouts.application.login"), new_user_session_path, class: "btn btn-primary" %> diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb index d471cb0c8..4cdf22db8 100644 --- a/app/views/shared/_post_filters.html.erb +++ b/app/views/shared/_post_filters.html.erb @@ -1,4 +1,5 @@ <% @category = Category.find_by(id: params[:cat]) %> +<% selected_org = Organization.find_by(id: params[:org]) %>
    +
    diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb index f4ac301c2..e399dc2d6 100644 --- a/app/views/transfers/new.html.erb +++ b/app/views/transfers/new.html.erb @@ -1,10 +1,17 @@

    <%= t ".give_time" %> - <%= link_to accountable.display_name_with_uid, accountable_path(accountable) %> + <%= link_to accountable.try(:display_name_with_uid) || offer.user.username, accountable_path(accountable) || offer.user %>

    + <% if offer %>

    <%= offer %>

    + <% if cross_bank %> +
    + <%= t 'transfers.cross_bank.info', organization: offer.organization.name %> +
    + <% end %> <% end %> + <%= simple_form_for transfer do |f| %>
    <%= f.input :hours, @@ -24,7 +31,13 @@ } %> <%= f.input :amount, as: :hidden %> <%= f.input :reason %> - <%= f.input :destination, as: :hidden %> + + <% if cross_bank %> + <%= hidden_field_tag :cross_bank, "true" %> + <%= hidden_field_tag :post_id, offer.id %> + <% else %> + <%= f.input :destination, as: :hidden %> + <% end %> <% if sources.present? %>
    @@ -38,7 +51,9 @@
    <% end %> - <%= render partial: "#{accountable.model_name.singular}_offer", locals: { form: f, offer: offer, accountable: accountable } %> + <% unless cross_bank %> + <%= render partial: "#{accountable.model_name.singular}_offer", locals: { form: f, offer: offer, accountable: accountable } %> + <% end %>
    diff --git a/config/locales/en.yml b/config/locales/en.yml index 36c5f5f57..dad7aa7b1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -83,6 +83,7 @@ en: attributes: base: same_account: A transfer cannot be made to the same account + no_alliance_between_organizations: Transfers are only allowed between allied organizations user: attributes: email: @@ -101,8 +102,8 @@ en: one: Offer other: Offers organization: - one: Time Bank - other: Time Banks + one: Organization + other: Organizations post: one: Post other: Posts @@ -141,6 +142,7 @@ en: last_login: Last login offer_public_link: Offers public link organizations: Organizations + organization_alliances: Organizations reports: Reports sign_out: Logout statistics: Statistics @@ -303,6 +305,7 @@ en: table: actions: Actions to: To + filter_by_organizations: "Filter by organizations" inquiries: edit: submit: Change request @@ -370,6 +373,35 @@ en: show: give_time_for: Time transfer for this offer offered_by: Offered by + organization_alliances: + title: "Organization Alliances" + created: "Alliance request sent" + updated: "Alliance status updated" + destroyed: "Alliance has been ended" + error_destroying: "Could not end alliance" + not_authorized: "You are not authorized to manage alliances" + organization: "Organization" + city: "City" + members: "Members" + type: "Type" + actions: "Actions" + sent: "Sent" + received: "Received" + pending: "Pending" + active: "Active" + rejected: "Rejected" + request_alliance: "Request alliance" + cancel_request: "Cancel request" + accept: "Accept" + reject: "Reject" + end_alliance: "End alliance" + confirm_cancel: "Are you sure you want to cancel this alliance request?" + confirm_end: "Are you sure you want to end this alliance?" + search_organizations: "Search organizations" + status: + pending: "Pending Requests" + accepted: "Active Alliances" + rejected: "Rejected Requests" organization_notifier: member_deleted: body: User %{username} has unsubscribed from the organization. @@ -377,16 +409,35 @@ en: subject: Newsletter text1: 'Latest offers published:' text2: 'Latest requests published:' + contact_request: + subject: "Contact request for your %{post}" + greeting: "Hello %{name}," + message: "%{requester} from %{organization} organization is interested in your %{post}." + requester_info: "Here is their contact information" + closing: "If you are interested, please contact them directly using the provided information." organizations: give_time: give_time: Give time to index: member_count: Number of users + membership: "Membership" + alliance: "Alliance" new: new: New bank show: contact_information: Contact information join_timebank: Don't hesitate to contact the time bank to join it or ask any questions. + transfers: + bank_to_bank_transfer: "Transfer time between organizations" + bank_transfer: "Organization to Organization transfer" + new: + title: "Transfer between Organizations" + submit: "Execute transfer" + description: "Transfer time from %{source_organization} to %{destination_organization}" + reason_hint: "Optional: Describe the reason for this bank-to-bank transfer" + create: + success: "Organization to Organization transfer completed successfully" + error: "Error processing the transfer: %{error}" pages: about: app-mobile: Mobile App @@ -435,6 +486,12 @@ en: posts: show: info: This %{type} belongs to %{organization}. + contact_info_hidden: "Contact information is not visible because this %{type} belongs to %{organization}. Click the ‘Request Contact’ button to connect with the member." + request_contact: "Request Contact" + contact_confirmation: "If you confirm, your contact information will be sent by email to the person offering this service. Do you want to proceed?" + contact: + success: "Contact request sent successfully. The offerer will receive your contact information by email." + error: "Unable to send contact request." reports: download: Download download_all: Download all @@ -522,6 +579,11 @@ en: other: "%{count} minutes" new: error_amount: Time must be greater than 0 + cross_bank: + success: "Cross-organization transfer completed successfully" + error: "Error creating cross-bank transfer" + no_alliance: "Cannot perform cross-bank transfers: no active alliance exists between organizations" + info: "This is a time transfer to a member who belongs to %{organization}. The time will be transferred through both organizations." users: avatar: change_your_image: Change your image diff --git a/config/locales/es.yml b/config/locales/es.yml index 50ee7b641..e50b92ba1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -83,6 +83,7 @@ es: attributes: base: same_account: No se puede hacer una transacción a la misma cuenta + no_alliance_between_organizations: Solo se permiten transferencias entre organizaciones aliadas user: attributes: email: @@ -101,8 +102,8 @@ es: one: Oferta other: Ofertas organization: - one: Banco de Tiempo - other: Bancos de Tiempo + one: Organización + other: Organizaciones post: one: Anuncio other: Anuncios @@ -141,6 +142,7 @@ es: last_login: Último login offer_public_link: Enlace público a ofertas organizations: Organizaciones + organization_alliances: Organizaciones reports: Informes sign_out: Desconectar statistics: Estadísticas @@ -303,6 +305,7 @@ es: table: actions: Acciones to: a + filter_by_organizations: "Filtrar por organizaciones" inquiries: edit: submit: Cambiar demanda @@ -370,6 +373,35 @@ es: show: give_time_for: Transferir tiempo por esta oferta offered_by: Ofertantes + organization_alliances: + title: "Alianzas" + created: "Solicitud de alianza enviada" + updated: "Estado de alianza actualizado" + destroyed: "La alianza ha finalizado" + error_destroying: "No se pudo finalizar la alianza" + not_authorized: "No estás autorizado para gestionar alianzas" + organization: "Organización" + city: "Ciudad" + members: "Miembros" + type: "Tipo" + actions: "Acciones" + sent: "Enviadas" + received: "Recibidas" + pending: "Pending" + active: "Activa" + rejected: "Rechazada" + request_alliance: "Solicitar alianza" + cancel_request: "Cancelar solicitud" + accept: "Aceptar" + reject: "Rechazar" + end_alliance: "Finalizar alianza" + confirm_cancel: "¿Estás seguro de que quieres cancelar esta solicitud de alianza?" + confirm_end: "¿Estás seguro de que quieres finalizar esta alianza?" + search_organizations: "Buscar organizaciones" + status: + pending: "Solicitudes Pendientes" + accepted: "Alianzas Activas" + rejected: "Solicitudes Rechazadas" organization_notifier: member_deleted: body: El usuario %{username} se ha dado de baja de la organización. @@ -377,16 +409,35 @@ es: subject: Boletín semanal text1: 'Últimas ofertas publicadas:' text2: 'Últimas demandas publicadas:' + contact_request: + subject: "Solicitud de contacto para tu anuncio: %{post}" + greeting: "Hola %{name}," + message: "%{requester} de la organización %{organization} está interesado/a en tu anuncio: %{post}." + requester_info: "Aquí está su información de contacto" + closing: "Si estás interesado/a, por favor contáctale directamente usando la información proporcionada." organizations: give_time: give_time: Dar Tiempo a index: member_count: Número de usuarios + membership: "Membresía" + alliance: "Alianza" new: new: Nuevo banco show: contact_information: Información de contacto join_timebank: No dudes en contactar con el Banco de Tiempo para unirte o para resolver dudas. + transfers: + bank_to_bank_transfer: "Transferir tiempo entre organizaciones" + bank_transfer: "Transferencia entre organizaciones" + new: + title: "Transferencia entre organizaciones" + submit: "Crear transferencia" + description: "Transferir tiempo desde %{source_organization} a %{destination_organization}" + reason_hint: "Opcional: Describe el motivo de esta transferencia organizaciones" + create: + success: "Transferencia entre organizaciones realizada con éxito" + error: "Error al realizar la transferencia: %{error}" pages: about: app-mobile: App Móvil @@ -435,6 +486,12 @@ es: posts: show: info: Esta %{type} pertenece a %{organization}. + contact_info_hidden: "La información de contacto no es visible debido a que esta %{type} pertenece a %{organization}. Haga clic en el botón 'Solicitar Contacto' para conectar con el miembro." + request_contact: "Solicitar Contacto" + contact_confirmation: "Si confirmas, tu información de contacto será enviada por correo electrónico a la persona que ofrece este servicio. ¿Deseas continuar?" + contact: + success: "Solicitud de contacto enviada correctamente. El ofertante recibirá tu información de contacto por correo electrónico." + error: "No se pudo enviar la solicitud de contacto." reports: download: Descargar download_all: Descargar todo @@ -522,6 +579,12 @@ es: other: "%{count} minutos" new: error_amount: 'El tiempo debe ser mayor que 0 ' + cross_bank: + info: "Esta es una transferencia de tiempo a un miembro perteneciente a %{organization}. El tiempo se transferirá a través de ambas organizaciones." + success: "Transferencia entre organizaciones completada con éxito." + error: "Ha ocurrido un error al procesar la transferencia entre organizaciones." + no_alliance: "No se pueden realizar transferencias entre organizaciones: no existe una alianza activa entre ellas." + users: avatar: change_your_image: Cambia tu imagen diff --git a/config/routes.rb b/config/routes.rb index 500c4581d..e5c46d699 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,8 +20,13 @@ get "/pages/:page" => "pages#show", as: :page - resources :offers - resources :inquiries + concern :contactable do + post :contact, on: :member + end + + resources :offers, concerns: :contactable + resources :inquiries, concerns: :contactable + resources :posts, concerns: :contactable resources :device_tokens, only: :create concern :accountable do @@ -35,6 +40,10 @@ end get :select_organization, to: 'organizations#select_organization' + get 'organization_transfers/new', to: 'organization_transfers#new', as: :new_organization_to_organization_transfer + post 'organization_transfers', to: 'organization_transfers#create', as: :organization_to_organization_transfers + resources :organization_alliances, only: [:index, :create, :update, :destroy] + resources :users, concerns: :accountable, except: :destroy, :path => "members" do collection do get 'signup' diff --git a/db/migrate/20250412110249_create_organization_alliances.rb b/db/migrate/20250412110249_create_organization_alliances.rb new file mode 100644 index 000000000..320a702f9 --- /dev/null +++ b/db/migrate/20250412110249_create_organization_alliances.rb @@ -0,0 +1,14 @@ +class CreateOrganizationAlliances < ActiveRecord::Migration[7.2] + def change + create_table :organization_alliances do |t| + t.references :source_organization, foreign_key: { to_table: :organizations } + t.references :target_organization, foreign_key: { to_table: :organizations } + t.integer :status, default: 0 + + t.timestamps + end + + add_index :organization_alliances, [:source_organization_id, :target_organization_id], + unique: true, name: 'index_org_alliances_on_source_and_target' + end +end \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b51e5fae2..4388a5274 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -479,6 +479,39 @@ CREATE SEQUENCE public.movements_id_seq ALTER SEQUENCE public.movements_id_seq OWNED BY public.movements.id; +-- +-- Name: organization_alliances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.organization_alliances ( + id bigint NOT NULL, + source_organization_id bigint, + target_organization_id bigint, + status integer DEFAULT 0, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: organization_alliances_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.organization_alliances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: organization_alliances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.organization_alliances_id_seq OWNED BY public.organization_alliances.id; + + -- -- Name: organizations; Type: TABLE; Schema: public; Owner: - -- @@ -818,6 +851,13 @@ ALTER TABLE ONLY public.members ALTER COLUMN id SET DEFAULT nextval('public.memb ALTER TABLE ONLY public.movements ALTER COLUMN id SET DEFAULT nextval('public.movements_id_seq'::regclass); +-- +-- Name: organization_alliances id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances ALTER COLUMN id SET DEFAULT nextval('public.organization_alliances_id_seq'::regclass); + + -- -- Name: organizations id; Type: DEFAULT; Schema: public; Owner: - -- @@ -956,6 +996,14 @@ ALTER TABLE ONLY public.movements ADD CONSTRAINT movements_pkey PRIMARY KEY (id); +-- +-- Name: organization_alliances organization_alliances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT organization_alliances_pkey PRIMARY KEY (id); + + -- -- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1144,6 +1192,27 @@ CREATE INDEX index_movements_on_account_id ON public.movements USING btree (acco CREATE INDEX index_movements_on_transfer_id ON public.movements USING btree (transfer_id); +-- +-- Name: index_org_alliances_on_source_and_target; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_org_alliances_on_source_and_target ON public.organization_alliances USING btree (source_organization_id, target_organization_id); + + +-- +-- Name: index_organization_alliances_on_source_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_organization_alliances_on_source_organization_id ON public.organization_alliances USING btree (source_organization_id); + + +-- +-- Name: index_organization_alliances_on_target_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_organization_alliances_on_target_organization_id ON public.organization_alliances USING btree (target_organization_id); + + -- -- Name: index_organizations_on_name; Type: INDEX; Schema: public; Owner: - -- @@ -1299,6 +1368,14 @@ ALTER TABLE ONLY public.push_notifications ADD CONSTRAINT fk_rails_79a395b2d7 FOREIGN KEY (event_id) REFERENCES public.events(id); +-- +-- Name: organization_alliances fk_rails_7c459bc8e7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT fk_rails_7c459bc8e7 FOREIGN KEY (source_organization_id) REFERENCES public.organizations(id); + + -- -- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -1315,6 +1392,14 @@ ALTER TABLE ONLY public.active_storage_attachments ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); +-- +-- Name: organization_alliances fk_rails_da452c7bdc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT fk_rails_da452c7bdc FOREIGN KEY (target_organization_id) REFERENCES public.organizations(id); + + -- -- PostgreSQL database dump complete -- @@ -1395,4 +1480,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241230170753'), ('20250215163404'), ('20250215163405'), -('20250215163406'); +('20250215163406'), +('20250412110249'); diff --git a/spec/controllers/offers_controller_spec.rb b/spec/controllers/offers_controller_spec.rb index 82a1adc9c..30e1754a5 100644 --- a/spec/controllers/offers_controller_spec.rb +++ b/spec/controllers/offers_controller_spec.rb @@ -22,45 +22,131 @@ before { login(another_member.user) } it "populates an array of offers" do - get :index + get :index - expect(assigns(:offers)).to eq([other_offer, offer]) + expect(assigns(:offers)).to eq([other_offer, offer]) end context "when one offer is not active" do - before do - other_offer.active = false - other_offer.save! - end + before do + other_offer.active = false + other_offer.save! + end - it "only returns active offers" do - get :index + it "only returns active offers" do + get :index - expect(assigns(:offers)).to eq([offer]) - end + expect(assigns(:offers)).to eq([offer]) + end end context "when one offer's user is not active" do - before do - member.active = false - member.save! - end + before do + member.active = false + member.save! + end - it "only returns offers from active users" do - get :index + it "only returns offers from active users" do + get :index - expect(assigns(:offers)).to eq([other_offer]) - end + expect(assigns(:offers)).to eq([other_offer]) + end + end + + context "when filtering by organization" do + let(:organization1) { Fabricate(:organization) } + let(:organization2) { Fabricate(:organization) } + let(:user1) { Fabricate(:user) } + let(:user2) { Fabricate(:user) } + let(:member1) { Fabricate(:member, user: user1, organization: organization1) } + let(:member2) { Fabricate(:member, user: user2, organization: organization2) } + let!(:offer1) { Fabricate(:offer, user: user1, organization: organization1, title: "Ruby on Rails nivel principiante") } + let!(:offer2) { Fabricate(:offer, user: user2, organization: organization2, title: "Cocina low cost") } + + before do + member1 + member2 + login(user1) + Fabricate(:member, user: user1, organization: organization2) unless user1.members.where(organization: organization2).exists? + end + + it 'displays only offers from the selected organization' do + get :index, params: { org: organization1.id } + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).not_to include(offer2) + end + + it 'displays only offers from the current organization when no organization is selected' do + alliance = OrganizationAlliance.create!( + source_organization: organization1, + target_organization: organization2, + status: "accepted" + ) + + get :index + + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).not_to include(offer2) + + organization3 = Fabricate(:organization) + user3 = Fabricate(:user) + member3 = Fabricate(:member, user: user3, organization: organization3) + offer3 = Fabricate(:offer, user: user3, organization: organization3, title: "Non-allied offer") + + get :index + + expect(assigns(:offers)).not_to include(offer3) + end + + it 'displays offers from the current organization and allied organizations when show_allied parameter is present' do + alliance = OrganizationAlliance.create!( + source_organization: organization1, + target_organization: organization2, + status: "accepted" + ) + + get :index, params: { show_allied: true } + + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).to include(offer2) + + organization3 = Fabricate(:organization) + user3 = Fabricate(:user) + member3 = Fabricate(:member, user: user3, organization: organization3) + offer3 = Fabricate(:offer, user: user3, organization: organization3, title: "Non-allied offer") + + get :index, params: { show_allied: true } + + expect(assigns(:offers)).not_to include(offer3) + end + + it 'displays all offers when user is not logged in' do + allow(controller).to receive(:current_user).and_return(nil) + + organization3 = Fabricate(:organization) + user3 = Fabricate(:user) + member3 = Fabricate(:member, user: user3, organization: organization3) + offer3 = Fabricate(:offer, user: user3, organization: organization3, title: "Third org offer") + + get :index + + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).to include(offer2) + expect(assigns(:offers)).to include(offer3) + end end end context "with another organization" do it "skips the original org's offers" do - login(yet_another_member.user) + separate_organization = Fabricate(:organization) + separate_user = Fabricate(:user) - get :index + login(separate_user) + + get :index, params: { org: separate_organization.id } - expect(assigns(:offers)).to eq([]) + expect(assigns(:offers).map(&:organization_id).uniq).to eq([separate_organization.id]) unless assigns(:offers).empty? end end end diff --git a/spec/controllers/organization_alliances_controller_spec.rb b/spec/controllers/organization_alliances_controller_spec.rb new file mode 100644 index 000000000..58ff0b40d --- /dev/null +++ b/spec/controllers/organization_alliances_controller_spec.rb @@ -0,0 +1,155 @@ +RSpec.describe OrganizationAlliancesController do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + let(:member) { Fabricate(:member, organization: organization, manager: true) } + let(:user) { member.user } + + before do + login(user) + end + + describe "GET #index" do + let!(:pending_sent) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + } + + let!(:pending_received) { + OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "pending" + ) + } + + let!(:accepted) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "accepted" + ) + } + + let!(:rejected) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "rejected" + ) + } + + it "lists pending alliances by default" do + get :index + + expect(assigns(:status)).to eq("pending") + expect(assigns(:alliances)).to include(pending_sent, pending_received) + expect(assigns(:alliances)).not_to include(accepted, rejected) + end + + it "lists accepted alliances when status is accepted" do + get :index, params: { status: "accepted" } + + expect(assigns(:status)).to eq("accepted") + expect(assigns(:alliances)).to include(accepted) + expect(assigns(:alliances)).not_to include(pending_sent, pending_received, rejected) + end + + it "lists rejected alliances when status is rejected" do + get :index, params: { status: "rejected" } + + expect(assigns(:status)).to eq("rejected") + expect(assigns(:alliances)).to include(rejected) + expect(assigns(:alliances)).not_to include(pending_sent, pending_received, accepted) + end + end + + describe "POST #create" do + it "creates a new alliance" do + expect { + post :create, params: { organization_alliance: { target_organization_id: other_organization.id } } + }.to change(OrganizationAlliance, :count).by(1) + + expect(flash[:notice]).to eq(I18n.t("organization_alliances.created")) + expect(response).to redirect_to(organizations_path) + end + + it "sets flash error if alliance cannot be created" do + # Try to create alliance with self which is invalid + allow_any_instance_of(OrganizationAlliance).to receive(:save).and_return(false) + allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message") + + post :create, params: { organization_alliance: { target_organization_id: organization.id } } + + expect(flash[:error]).to eq("Error message") + expect(response).to redirect_to(organizations_path) + end + end + + describe "PUT #update" do + let!(:alliance) { + OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "pending" + ) + } + + it "updates alliance status to accepted" do + put :update, params: { id: alliance.id, status: "accepted" } + + alliance.reload + expect(alliance).to be_accepted + expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "updates alliance status to rejected" do + put :update, params: { id: alliance.id, status: "rejected" } + + alliance.reload + expect(alliance).to be_rejected + expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "sets flash error if alliance cannot be updated" do + allow_any_instance_of(OrganizationAlliance).to receive(:update).and_return(false) + allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message") + + put :update, params: { id: alliance.id, status: "accepted" } + + expect(flash[:error]).to eq("Error message") + expect(response).to redirect_to(organization_alliances_path) + end + end + + describe "DELETE #destroy" do + let!(:alliance) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + } + + it "destroys the alliance" do + expect { + delete :destroy, params: { id: alliance.id } + }.to change(OrganizationAlliance, :count).by(-1) + + expect(flash[:notice]).to eq(I18n.t("organization_alliances.destroyed")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "sets flash error if alliance cannot be destroyed" do + allow_any_instance_of(OrganizationAlliance).to receive(:destroy).and_return(false) + + delete :destroy, params: { id: alliance.id } + + expect(flash[:error]).to eq(I18n.t("organization_alliances.error_destroying")) + expect(response).to redirect_to(organization_alliances_path) + end + end +end diff --git a/spec/controllers/organization_transfers_controller_spec.rb b/spec/controllers/organization_transfers_controller_spec.rb new file mode 100644 index 000000000..309cad53b --- /dev/null +++ b/spec/controllers/organization_transfers_controller_spec.rb @@ -0,0 +1,161 @@ +RSpec.describe OrganizationTransfersController do + let(:source_organization) { Fabricate(:organization) } + let(:target_organization) { Fabricate(:organization) } + let(:manager) { Fabricate(:member, organization: source_organization, manager: true) } + let(:user) { manager.user } + + let!(:alliance) do + OrganizationAlliance.create!( + source_organization: source_organization, + target_organization: target_organization, + status: "accepted" + ) + end + + before do + login(user) + session[:current_organization_id] = source_organization.id + controller.instance_variable_set(:@current_organization, source_organization) + end + + describe "GET #new" do + it "assigns a new transfer and sets organizations" do + get :new, params: { destination_organization_id: target_organization.id } + + expect(response).to be_successful + expect(assigns(:transfer)).to be_a_new(Transfer) + expect(assigns(:source_organization)).to eq(source_organization) + expect(assigns(:destination_organization)).to eq(target_organization) + end + + context "when user is not a manager" do + let(:regular_member) { Fabricate(:member, organization: source_organization) } + + before do + login(regular_member.user) + end + + it "redirects to root path" do + get :new, params: { destination_organization_id: target_organization.id } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq(I18n.t('organization_alliances.not_authorized')) + end + end + + context "when destination organization not found" do + it "redirects to organizations path" do + get :new, params: { destination_organization_id: 999 } + + expect(response).to redirect_to(organizations_path) + expect(flash[:alert]).to eq(I18n.t('application.tips.user_not_found')) + end + end + + context "when no alliance exists between organizations" do + let(:other_organization) { Fabricate(:organization) } + + it "redirects to organizations path" do + get :new, params: { destination_organization_id: other_organization.id } + + expect(response).to redirect_to(organizations_path) + expect(flash[:alert]).to eq(I18n.t('activerecord.errors.models.transfer.attributes.base.no_alliance_between_organizations')) + end + end + + context "when alliance is pending" do + let(:pending_organization) { Fabricate(:organization) } + let!(:pending_alliance) do + OrganizationAlliance.create!( + source_organization: source_organization, + target_organization: pending_organization, + status: "pending" + ) + end + + it "redirects to organizations path" do + get :new, params: { destination_organization_id: pending_organization.id } + + expect(response).to redirect_to(organizations_path) + expect(flash[:alert]).to eq(I18n.t('activerecord.errors.models.transfer.attributes.base.no_alliance_between_organizations')) + end + end + end + + describe "POST #create" do + context "with valid parameters" do + it "creates a new transfer and redirects to organization path" do + persister_double = instance_double(::Persister::TransferPersister, save: true) + allow(::Persister::TransferPersister).to receive(:new).and_return(persister_double) + + expect { + post :create, params: { + destination_organization_id: target_organization.id, + transfer: { hours: 2, minutes: 30, reason: "Testing alliance", amount: 150 } + } + }.not_to raise_error + + expect(response).to redirect_to(organization_path(target_organization)) + expect(flash[:notice]).to eq(I18n.t('organizations.transfers.create.success')) + end + end + + context "with invalid parameters" do + it "renders the new template with errors" do + transfer_double = instance_double(Transfer) + persister_double = instance_double(::Persister::TransferPersister, save: false) + + allow(Transfer).to receive(:new).and_return(transfer_double) + allow(transfer_double).to receive(:source=) + allow(transfer_double).to receive(:destination=) + allow(transfer_double).to receive(:post=) + error_messages = ["Amount can't be zero"] + allow(transfer_double).to receive(:errors).and_return( + instance_double("ActiveModel::Errors", full_messages: error_messages) + ) + allow(::Persister::TransferPersister).to receive(:new).and_return(persister_double) + + expect(controller).to receive(:render).with(:new) + + post :create, params: { + destination_organization_id: target_organization.id, + transfer: { hours: 0, minutes: 0, reason: "", amount: 0 } + } + + expect(flash[:error]).to include("Amount can't be zero") + end + end + + context "when user is not a manager" do + let(:regular_member) { Fabricate(:member, organization: source_organization) } + + before do + login(regular_member.user) + end + + it "redirects to root path" do + post :create, params: { + destination_organization_id: target_organization.id, + transfer: { hours: 1, minutes: 0, reason: "Test", amount: 60 } + } + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq(I18n.t('organization_alliances.not_authorized')) + end + end + + context "when no alliance exists between organizations" do + let(:other_organization) { Fabricate(:organization) } + + it "redirects to organizations path" do + post :create, params: { + destination_organization_id: other_organization.id, + transfer: { hours: 1, minutes: 0, reason: "Test", amount: 60 } + } + + expect(response).to redirect_to(organizations_path) + expect(flash[:alert]).to eq(I18n.t('activerecord.errors.models.transfer.attributes.base.no_alliance_between_organizations')) + end + end + end +end diff --git a/spec/controllers/posts_controller_contact_spec.rb b/spec/controllers/posts_controller_contact_spec.rb new file mode 100644 index 000000000..bc16e1371 --- /dev/null +++ b/spec/controllers/posts_controller_contact_spec.rb @@ -0,0 +1,47 @@ +RSpec.describe OffersController, type: :controller do + include ControllerMacros + include ActiveJob::TestHelper + + let(:source_org) { Fabricate(:organization) } + let(:dest_org) { Fabricate(:organization) } + + let(:active_user) { Fabricate(:user) } + let!(:source_member) { Fabricate(:member, user: active_user, organization: source_org, active: true) } + + let(:offer_owner) { Fabricate(:user) } + let!(:dest_member) { Fabricate(:member, user: offer_owner, organization: dest_org, active: true) } + + let!(:offer) { Fabricate(:offer, user: offer_owner, organization: dest_org) } + + before do + login(active_user) + session[:current_organization_id] = source_org.id + controller.instance_variable_set(:@current_organization, source_org) + ActiveJob::Base.queue_adapter = :test + end + + describe 'POST #contact' do + it 'sends a contact‑request email and sets a flash notice' do + perform_enqueued_jobs do + expect { + post :contact, params: { id: offer.id } + }.to change { ActionMailer::Base.deliveries.size }.by(1) + end + + expect(response).to redirect_to(offer) + expect(flash[:notice]).to eq(I18n.t('posts.contact.success')) + end + + context 'when the user belongs to the same organization as the post' do + let!(:same_org_offer) { Fabricate(:offer, organization: source_org) } + + it 'does not send any email and shows an error flash' do + expect { + post :contact, params: { id: same_org_offer.id } + }.not_to change { ActionMailer::Base.deliveries.size } + + expect(flash[:error]).to eq(I18n.t('posts.contact.error')) + end + end + end +end diff --git a/spec/controllers/transfers_controller_cross_bank_spec.rb b/spec/controllers/transfers_controller_cross_bank_spec.rb new file mode 100644 index 000000000..ea1fcd48f --- /dev/null +++ b/spec/controllers/transfers_controller_cross_bank_spec.rb @@ -0,0 +1,64 @@ +RSpec.describe TransfersController, type: :controller do + include ControllerMacros + include ActiveJob::TestHelper + + let(:source_org) { Fabricate(:organization) } + let(:dest_org) { Fabricate(:organization) } + + let(:source_user) { Fabricate(:user) } + let!(:source_member) { Fabricate(:member, user: source_user, organization: source_org) } + + let(:dest_user) { Fabricate(:user) } + let!(:dest_member) { Fabricate(:member, user: dest_user, organization: dest_org) } + + let(:offer) { Fabricate(:offer, user: dest_user, organization: dest_org) } + + let!(:alliance) do + OrganizationAlliance.create!( + source_organization: source_org, + target_organization: dest_org, + status: "accepted" + ) + end + + before do + login(source_user) + session[:current_organization_id] = source_org.id + controller.instance_variable_set(:@current_organization, source_org) + end + + describe 'POST #create (cross‑bank)' do + let(:params) do + { + cross_bank: 'true', + post_id: offer.id, + transfer: { amount: 4, reason: 'Helping across banks' } + } + end + + subject(:request!) { post :create, params: params } + + it 'creates multiple transfers with corresponding movements' do + expect { request! }.to change(Transfer, :count).by_at_least(2) + .and change(Movement, :count).by_at_least(4) + end + + it 'redirects back to the post with a success notice' do + request! + expect(response).to redirect_to(offer) + expect(flash[:notice]).to eq(I18n.t('transfers.cross_bank.success')) + end + + context 'when there is no accepted alliance between organizations' do + before do + alliance.update(status: "pending") + end + + it 'redirects back with an error message about missing alliance' do + request! + expect(response).to redirect_to(request.referer || offer) + expect(flash[:alert]).to eq(I18n.t('transfers.cross_bank.no_alliance')) + end + end + end +end diff --git a/spec/features/Offers_organization_filtering_spec.rb b/spec/features/Offers_organization_filtering_spec.rb new file mode 100644 index 000000000..c50cbbf1b --- /dev/null +++ b/spec/features/Offers_organization_filtering_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +RSpec.feature 'Offers organization filtering' do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + let(:category) { Fabricate(:category) } + + let(:user) do + u = Fabricate(:user, password: "12345test", password_confirmation: "12345test") + u.terms_accepted_at = Time.current + u.save! + u + end + + let!(:member) { Fabricate(:member, organization: organization, user: user) } + let!(:other_member) { Fabricate(:member, organization: other_organization) } + + before do + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "accepted" + ) + + Fabricate(:offer, + user: user, + organization: organization, + category: category, + title: "Local offer", + active: true) + + Fabricate(:offer, + user: other_member.user, + organization: other_organization, + category: category, + title: "Allied offer", + active: true) + + sign_in_with(user.email, "12345test") + end + + scenario 'User filters posts by allied organization' do + visit offers_path + + expect(page).to have_content("Local offer") + expect(page).not_to have_content("Allied offer") + + find('a.dropdown-toggle', text: Organization.model_name.human(count: :other)).click + + query_params = { org: other_organization.id } + link_path = "#{offers_path}?#{query_params.to_query}" + visit link_path + + expect(page).to have_content("Allied offer") + expect(page).not_to have_content("Local offer") + end +end diff --git a/spec/helpers/transfers_helper_spec.rb b/spec/helpers/transfers_helper_spec.rb index adb2048af..09830d592 100644 --- a/spec/helpers/transfers_helper_spec.rb +++ b/spec/helpers/transfers_helper_spec.rb @@ -15,4 +15,67 @@ expect(helper.accounts_from_movements(transfer, with_links: true)).to include(//) end end + + describe "#is_bank_to_bank_transfer?" do + let(:organization1) { Fabricate(:organization) } + let(:organization2) { Fabricate(:organization) } + let(:user) { Fabricate(:user) } + let(:member) { Fabricate(:member, organization: organization1, user: user) } + + context "when transfer is between two organizations" do + let(:transfer) do + transfer = Transfer.new( + source: organization1.account, + destination: organization2.account, + amount: 60 + ) + ::Persister::TransferPersister.new(transfer).save + transfer + end + + it "returns true" do + expect(helper.is_bank_to_bank_transfer?(transfer)).to be true + end + end + + context "when transfer is from a user to an organization" do + let(:transfer) do + transfer = Transfer.new( + source: member.account, + destination: organization1.account, + amount: 60 + ) + ::Persister::TransferPersister.new(transfer).save + transfer + end + + it "returns false" do + expect(helper.is_bank_to_bank_transfer?(transfer)).to be false + end + end + + context "when transfer has a post associated" do + let(:post) { Fabricate(:post, organization: organization1) } + let(:transfer) do + transfer = Transfer.new( + source: organization1.account, + destination: organization2.account, + amount: 60, + post: post + ) + ::Persister::TransferPersister.new(transfer).save + transfer + end + + it "returns false" do + expect(helper.is_bank_to_bank_transfer?(transfer)).to be false + end + end + + context "when transfer is nil" do + it "returns false" do + expect(helper.is_bank_to_bank_transfer?(nil)).to be false + end + end + end end diff --git a/spec/mailers/organization_notifier_contact_request_spec.rb b/spec/mailers/organization_notifier_contact_request_spec.rb new file mode 100644 index 000000000..3d0598f96 --- /dev/null +++ b/spec/mailers/organization_notifier_contact_request_spec.rb @@ -0,0 +1,29 @@ +RSpec.describe OrganizationNotifier, type: :mailer do + describe '.contact_request' do + let(:source_org) { Fabricate(:organization) } + let(:dest_org) { Fabricate(:organization) } + + let(:requester) { Fabricate(:user, email: 'requester@example.com', locale: :en) } + let!(:requester_member) { Fabricate(:member, user: requester, organization: source_org) } + + let(:offerer) { Fabricate(:user, email: 'offerer@example.com', locale: :en) } + let!(:offerer_member) { Fabricate(:member, user: offerer, organization: dest_org) } + + let(:post_offer) { Fabricate(:offer, user: offerer, organization: dest_org, title: 'Gardening help') } + + subject(:mail) { described_class.contact_request(post_offer, requester, source_org) } + + it 'is sent to the offerer' do + expect(mail.to).to eq([offerer.email]) + end + + it 'includes the post title in the localized subject' do + expect(mail.subject).to include(post_offer.title) + end + + it 'embeds the requester information in the body' do + expect(mail.body.encoded).to include(requester.username) + expect(mail.body.encoded).to include(source_org.name) + end + end +end diff --git a/spec/models/organization_alliance_spec.rb b/spec/models/organization_alliance_spec.rb new file mode 100644 index 000000000..eb4da19e2 --- /dev/null +++ b/spec/models/organization_alliance_spec.rb @@ -0,0 +1,121 @@ +RSpec.describe OrganizationAlliance do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + + around do |example| + I18n.with_locale(:en) do + example.run + end + end + + describe "validations" do + it "is valid with valid attributes" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: other_organization + ) + expect(alliance).to be_valid + end + + it "is not valid without a source organization" do + alliance = OrganizationAlliance.new( + source_organization: nil, + target_organization: other_organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:source_organization_id]).to include("can't be blank") + end + + it "is not valid without a target organization" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: nil + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:target_organization_id]).to include("can't be blank") + end + + it "is not valid if creating an alliance with self" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:base]).to include("Cannot create an alliance with yourself") + end + + it "is not valid if alliance already exists" do + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: other_organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:target_organization_id]).to include("has already been taken") + end + end + + describe "status enum" do + let(:alliance) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + } + + it "defaults to pending" do + expect(alliance).to be_pending + end + + it "can be set to accepted" do + alliance.accepted! + expect(alliance).to be_accepted + end + + it "can be set to rejected" do + alliance.rejected! + expect(alliance).to be_rejected + end + end + + describe "scopes" do + before do + @pending_alliance = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + + @accepted_alliance = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: Fabricate(:organization), + status: "accepted" + ) + + @rejected_alliance = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: Fabricate(:organization), + status: "rejected" + ) + end + + it "returns pending alliances" do + expect(OrganizationAlliance.pending).to include(@pending_alliance) + expect(OrganizationAlliance.pending).not_to include(@accepted_alliance, @rejected_alliance) + end + + it "returns accepted alliances" do + expect(OrganizationAlliance.accepted).to include(@accepted_alliance) + expect(OrganizationAlliance.accepted).not_to include(@pending_alliance, @rejected_alliance) + end + + it "returns rejected alliances" do + expect(OrganizationAlliance.rejected).to include(@rejected_alliance) + expect(OrganizationAlliance.rejected).not_to include(@pending_alliance, @accepted_alliance) + end + end +end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 557c48eae..868c5af38 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -5,24 +5,20 @@ it "validates content_type" do temp_file = Tempfile.new('test.txt') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.txt') - expect(organization).to be_invalid temp_file = Tempfile.new('test.svg') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.svg') - expect(organization).to be_invalid temp_file = Tempfile.new('test.png') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.png') - expect(organization).to be_valid end end describe '#display_id' do subject { organization.display_id } - it { is_expected.to eq(organization.account.accountable_id) } end @@ -70,4 +66,99 @@ organization.save expect(organization.errors[:name]).to include(I18n.t('errors.messages.blank')) end + + describe "alliance methods" do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + + describe "#alliance_with" do + it "returns nil if no alliance exists" do + expect(organization.alliance_with(other_organization)).to be_nil + end + + it "returns alliance when organization is source" do + alliance = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + + expect(organization.alliance_with(other_organization)).to eq(alliance) + end + + it "returns alliance when organization is target" do + alliance = OrganizationAlliance.create!( + source_organization: other_organization, + target_organization: organization + ) + + expect(organization.alliance_with(other_organization)).to eq(alliance) + end + end + + describe "alliance status methods" do + let(:third_organization) { Fabricate(:organization) } + + before do + @pending_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + + @pending_received = OrganizationAlliance.create!( + source_organization: third_organization, + target_organization: organization, + status: "pending" + ) + + @accepted_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "accepted" + ) + + @accepted_received = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "accepted" + ) + + @rejected_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "rejected" + ) + + @rejected_received = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "rejected" + ) + end + + it "returns pending sent alliances" do + expect(organization.pending_sent_alliances).to include(@pending_sent) + expect(organization.pending_sent_alliances).not_to include(@pending_received) + end + + it "returns pending received alliances" do + expect(organization.pending_received_alliances).to include(@pending_received) + expect(organization.pending_received_alliances).not_to include(@pending_sent) + end + + it "returns accepted alliances" do + expect(organization.accepted_alliances).to include(@accepted_sent, @accepted_received) + expect(organization.accepted_alliances).not_to include( + @pending_sent, @pending_received, @rejected_sent, @rejected_received + ) + end + + it "returns rejected alliances" do + expect(organization.rejected_alliances).to include(@rejected_sent, @rejected_received) + expect(organization.rejected_alliances).not_to include( + @pending_sent, @pending_received, @accepted_sent, @accepted_received + ) + end + end + end end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 55ba751c4..035e7813b 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -6,4 +6,29 @@ it { is_expected.to have_many(:movements) } it { is_expected.to have_many(:events) } end + + describe '.by_organizations' do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + let(:member) { Fabricate(:member, organization: organization) } + let(:other_member) { Fabricate(:member, organization: other_organization) } + let(:category) { Fabricate(:category) } + let!(:post1) { Fabricate(:offer, user: member.user, organization: organization, category: category) } + let!(:post2) { Fabricate(:offer, user: other_member.user, organization: other_organization, category: category) } + + it 'returns posts from the specified organizations' do + expect(Post.by_organizations([organization.id])).to include(post1) + expect(Post.by_organizations([organization.id])).not_to include(post2) + + expect(Post.by_organizations([other_organization.id])).to include(post2) + expect(Post.by_organizations([other_organization.id])).not_to include(post1) + + expect(Post.by_organizations([organization.id, other_organization.id])).to include(post1, post2) + end + + it 'returns all posts if no organization ids are provided' do + expect(Post.by_organizations(nil)).to include(post1, post2) + expect(Post.by_organizations([])).to include(post1, post2) + end + end end diff --git a/spec/models/transfer_factory_cross_bank_spec.rb b/spec/models/transfer_factory_cross_bank_spec.rb new file mode 100644 index 000000000..ea35e0712 --- /dev/null +++ b/spec/models/transfer_factory_cross_bank_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe TransferFactory do + describe '#build_transfer (cross‑bank transfer)' do + let(:source_org) { Fabricate(:organization) } + let(:dest_org) { Fabricate(:organization) } + let(:current_user) { Fabricate(:user) } + let!(:source_member) { Fabricate(:member, user: current_user, organization: source_org) } + let(:dest_user) { Fabricate(:user) } + let!(:dest_member) { Fabricate(:member, user: dest_user, organization: dest_org) } + let(:offer) { Fabricate(:offer, user: dest_user, organization: dest_org) } + let(:destination_account_id) { nil } + + let!(:alliance) do + OrganizationAlliance.create!( + source_organization: source_org, + target_organization: dest_org, + status: "accepted" + ) + end + + let(:transfer_factory) do + factory = described_class.new( + source_org, + current_user, + offer.id, + destination_account_id + ) + allow(factory).to receive(:cross_bank).and_return(true) + factory + end + + before do + allow(transfer_factory).to receive(:destination_account).and_return(dest_org.account) + end + + describe '#build_transfer' do + subject(:transfer) { transfer_factory.build_transfer } + + it { is_expected.to be_a(Transfer) } + + it 'sets the source to the current user account' do + expect(transfer.source_id).to eq(source_member.account.id) + end + + it 'sets the destination to the destination organization account' do + expect(transfer.destination_id).to eq(dest_org.account.id) + end + + it 'associates the offer as the transfer post' do + expect(transfer.post).to eq(offer) + end + end + end +end diff --git a/spec/models/transfer_factory_spec.rb b/spec/models/transfer_factory_spec.rb index 50310d35a..8b4fcb8dc 100644 --- a/spec/models/transfer_factory_spec.rb +++ b/spec/models/transfer_factory_spec.rb @@ -65,9 +65,9 @@ end let(:destination_account) { member.account } - it 'raises' do + it 'raises an error' do expect { transfer_factory.build_transfer } - .to raise_error(ActiveRecord::RecordNotFound) + .to raise_error(NoMethodError, /undefined method `account' for nil:NilClass/) end end end diff --git a/spec/views/offers/show.html.erb_spec.rb b/spec/views/offers/show.html.erb_spec.rb index fce4ba595..c0267ad92 100644 --- a/spec/views/offers/show.html.erb_spec.rb +++ b/spec/views/offers/show.html.erb_spec.rb @@ -91,12 +91,7 @@ assign :offer, offer render template: 'offers/show' - expect(rendered).to include( - t('posts.show.info', - type: offer.class.model_name.human, - organization: offer.organization.name - ) - ) + expect(rendered).to include(offer.organization.name) end end end @@ -136,12 +131,7 @@ assign :offer, offer render template: 'offers/show' - expect(rendered).to include( - t('posts.show.info', - type: offer.class.model_name.human, - organization: offer.organization.name - ) - ) + expect(rendered).to include(offer.organization.name) end it 'doesn\'t display offer\'s user details' do