From 88c808b5f15e60a053c484ed391dedfd26232ec0 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 13 Apr 2025 15:41:48 +0200 Subject: [PATCH 01/37] alliance between diffents timebanks --- .../organization_alliances_controller.rb | 79 ++++++ app/models/organization.rb | 23 ++ app/models/organization_alliance.rb | 23 ++ app/policies/organization_alliance_policy.rb | 11 + .../_organization_listings_menu.html.erb | 6 + .../organization_alliances/index.html.erb | 102 +++++++ .../organizations/_alliance_button.html.erb | 16 ++ .../organizations/_organizations_row.html.erb | 7 +- app/views/organizations/index.html.erb | 7 +- config/locales/en.yml | 34 ++- config/locales/es.yml | 34 ++- config/routes.rb | 2 + ...412110249_create_organization_alliances.rb | 14 + db/structure.sql | 253 ++++++++++++------ 14 files changed, 517 insertions(+), 94 deletions(-) create mode 100644 app/controllers/organization_alliances_controller.rb create mode 100644 app/models/organization_alliance.rb create mode 100644 app/policies/organization_alliance_policy.rb create mode 100644 app/views/organization_alliances/index.html.erb create mode 100644 app/views/organizations/_alliance_button.html.erb create mode 100644 db/migrate/20250412110249_create_organization_alliances.rb diff --git a/app/controllers/organization_alliances_controller.rb b/app/controllers/organization_alliances_controller.rb new file mode 100644 index 00000000..27c340c2 --- /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/models/organization.rb b/app/models/organization.rb index 90847ee3..e2cc5dff 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,27 @@ 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 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 00000000..6d242358 --- /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/policies/organization_alliance_policy.rb b/app/policies/organization_alliance_policy.rb new file mode 100644 index 00000000..53c3af04 --- /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 aeb701c5..20f246ca 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/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb new file mode 100644 index 00000000..cbb9a2a8 --- /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-success me-2' %> + <%= 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') } %> +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb new file mode 100644 index 00000000..010616a7 --- /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 %> \ No newline at end of file diff --git a/app/views/organizations/_organizations_row.html.erb b/app/views/organizations/_organizations_row.html.erb index f68caa55..1a834b24 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 %> + \ No newline at end of file diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb index a6954fa5..3860b986 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 %> @@ -37,4 +40,4 @@ <%= paginate @organizations %> - + \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 36c5f5f5..1d6cf127 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -141,6 +141,7 @@ en: last_login: Last login offer_public_link: Offers public link organizations: Organizations + organization_alliances: Organizations reports: Reports sign_out: Logout statistics: Statistics @@ -370,6 +371,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. @@ -382,6 +412,8 @@ en: give_time: Give time to index: member_count: Number of users + membership: "Membership" + alliance: "Alliance" new: new: New bank show: @@ -588,4 +620,4 @@ en: last: Last next: Next previous: Previous - truncate: Truncate + truncate: Truncate \ No newline at end of file diff --git a/config/locales/es.yml b/config/locales/es.yml index 50ee7b64..4bad6558 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -141,6 +141,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 @@ -370,6 +371,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. @@ -382,6 +412,8 @@ es: give_time: Dar Tiempo a index: member_count: Número de usuarios + membership: "Membresía" + alliance: "Alianza" new: new: Nuevo banco show: @@ -588,4 +620,4 @@ es: last: Ultima » next: Siguiente › previous: "‹ Anterior" - truncate: "…" + truncate: "…" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 500c4581..2eba7fd1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,8 @@ end get :select_organization, to: 'organizations#select_organization' + 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 00000000..320a702f --- /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 b51e5fae..21304444 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,25 +1,13 @@ SET statement_timeout = 0; SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; - --- --- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; - - --- --- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: - --- - -COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; - +SET row_security = off; -- -- Name: hstore; Type: EXTENSION; Schema: -; Owner: - @@ -82,7 +70,7 @@ CREATE FUNCTION public.posts_trigger() RETURNS trigger SET default_tablespace = ''; -SET default_with_oids = false; +SET default_table_access_method = heap; -- -- Name: accounts; Type: TABLE; Schema: public; Owner: - @@ -479,6 +467,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 +839,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 +984,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 +1180,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: - -- @@ -1232,7 +1289,7 @@ CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING b -- Name: posts tsvectorupdate; Type: TRIGGER; Schema: public; Owner: - -- -CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON public.posts FOR EACH ROW EXECUTE PROCEDURE public.posts_trigger(); +CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON public.posts FOR EACH ROW EXECUTE FUNCTION public.posts_trigger(); -- @@ -1299,6 +1356,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 +1380,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 -- @@ -1322,77 +1395,79 @@ ALTER TABLE ONLY public.active_storage_attachments SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES -('1'), -('2'), -('20121019101022'), -('20121104004639'), -('20121104085711'), -('20121121233818'), -('20130214175758'), -('20130214181128'), -('20130222185624'), -('20130425165150'), -('20130508085004'), -('20130513092219'), -('20130514094755'), -('20130618210236'), -('20130621102219'), -('20130621103053'), -('20130621103501'), -('20130621105452'), -('20130703233851'), -('20130703234011'), -('20130703234042'), -('20130723160206'), -('20131017144321'), -('20131025202608'), -('20131027215517'), -('20131029202724'), -('20131103221044'), -('20131104004235'), -('20131104013634'), -('20131104013829'), -('20131104032622'), -('20131220160257'), -('20131227110122'), -('20131227142805'), -('20131227155440'), -('20131231110424'), -('20140119161433'), -('20140513141718'), -('20140514225527'), -('20150329193421'), -('20150330200315'), -('20150422162806'), -('20180221161343'), -('20180501093846'), -('20180514193153'), -('20180524143938'), -('20180525141138'), -('20180529144243'), -('20180530180546'), -('20180604145622'), -('20180828160700'), -('20180831161349'), -('20180924164456'), -('20181004200104'), -('20190319121401'), -('20190322180602'), -('20190411192828'), -('20190412163011'), -('20190523213421'), -('20190523225323'), -('20210423193937'), -('20210424174640'), -('20210502160343'), -('20210503201944'), -('20221016192111'), -('20230312231058'), -('20230314233504'), -('20230401114456'), -('20231120164231'), -('20231120164346'), -('20241230170753'), -('20250215163404'), +('20250412110249'), +('20250215163406'), ('20250215163405'), -('20250215163406'); +('20250215163404'), +('20241230170753'), +('20231120164346'), +('20231120164231'), +('20230401114456'), +('20230314233504'), +('20230312231058'), +('20221016192111'), +('20210503201944'), +('20210502160343'), +('20210424174640'), +('20210423193937'), +('20190523225323'), +('20190523213421'), +('20190412163011'), +('20190411192828'), +('20190322180602'), +('20190319121401'), +('20181004200104'), +('20180924164456'), +('20180831161349'), +('20180828160700'), +('20180604145622'), +('20180530180546'), +('20180529144243'), +('20180525141138'), +('20180524143938'), +('20180514193153'), +('20180501093846'), +('20180221161343'), +('20150422162806'), +('20150330200315'), +('20150329193421'), +('20140514225527'), +('20140513141718'), +('20140119161433'), +('20131231110424'), +('20131227155440'), +('20131227142805'), +('20131227110122'), +('20131220160257'), +('20131104032622'), +('20131104013829'), +('20131104013634'), +('20131104004235'), +('20131103221044'), +('20131029202724'), +('20131027215517'), +('20131025202608'), +('20131017144321'), +('20130723160206'), +('20130703234042'), +('20130703234011'), +('20130703233851'), +('20130621105452'), +('20130621103501'), +('20130621103053'), +('20130621102219'), +('20130618210236'), +('20130514094755'), +('20130513092219'), +('20130508085004'), +('20130425165150'), +('20130222185624'), +('20130214181128'), +('20130214175758'), +('20121121233818'), +('20121104085711'), +('20121104004639'), +('20121019101022'), +('2'), +('1'); + From 838cbbe348ff66deef549a63ef870b1c2d3b0fdb Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 13 Apr 2025 16:43:09 +0200 Subject: [PATCH 02/37] Bottom correction --- app/views/organization_alliances/index.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb index cbb9a2a8..8b10b742 100644 --- a/app/views/organization_alliances/index.html.erb +++ b/app/views/organization_alliances/index.html.erb @@ -71,11 +71,11 @@ 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-success me-2' %> + class: 'btn btn-primary' %> <%= link_to t('organization_alliances.reject'), organization_alliance_path(alliance, status: 'rejected'), method: :put, From 3d63279d415a2d5e5ef4dc57fad9cbc9b2bb258c Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 13 Apr 2025 17:32:47 +0200 Subject: [PATCH 03/37] class bottom corrected --- app/views/organizations/_alliance_button.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb index 010616a7..fc7f5d09 100644 --- a/app/views/organizations/_alliance_button.html.erb +++ b/app/views/organizations/_alliance_button.html.erb @@ -9,7 +9,7 @@ <% elsif alliance.pending? %> <%= t('organization_alliances.pending') %> <% elsif alliance.accepted? %> - <%= t('organization_alliances.active') %> + <%= t('organization_alliances.active') %> <% elsif alliance.rejected? %> <%= t('organization_alliances.rejected') %> <% end %> From f6616c9e9343e2d8000c70324595238158d2395a Mon Sep 17 00:00:00 2001 From: gmartincor Date: Mon, 14 Apr 2025 08:59:18 +0200 Subject: [PATCH 04/37] tests --- .../organization_alliances_controller_spec.rb | 155 ++++++++++++++++++ spec/models/organization_alliance_spec.rb | 121 ++++++++++++++ spec/models/organization_spec.rb | 99 ++++++++++- 3 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 spec/controllers/organization_alliances_controller_spec.rb create mode 100644 spec/models/organization_alliance_spec.rb diff --git a/spec/controllers/organization_alliances_controller_spec.rb b/spec/controllers/organization_alliances_controller_spec.rb new file mode 100644 index 00000000..58ff0b40 --- /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/models/organization_alliance_spec.rb b/spec/models/organization_alliance_spec.rb new file mode 100644 index 00000000..eb4da19e --- /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 557c48ea..868c5af3 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 From 7e69876efeef0b7e3d3b5ef3711f84331c43ac81 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 15 Apr 2025 19:53:48 +0200 Subject: [PATCH 05/37] Filter offer by organizations --- .../application/organizations_filter.js | 33 ++++++++++++++++ app/controllers/posts_controller.rb | 6 --- app/helpers/organizations_helper.rb | 17 ++++++++ app/models/post.rb | 3 ++ app/views/inquiries/index.html.erb | 2 +- app/views/offers/index.html.erb | 2 +- app/views/shared/_post_filters.html.erb | 39 +++++++++++++++++-- config/locales/en.yml | 1 + config/locales/es.yml | 5 ++- 9 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 app/assets/javascripts/application/organizations_filter.js create mode 100644 app/helpers/organizations_helper.rb diff --git a/app/assets/javascripts/application/organizations_filter.js b/app/assets/javascripts/application/organizations_filter.js new file mode 100644 index 00000000..96414911 --- /dev/null +++ b/app/assets/javascripts/application/organizations_filter.js @@ -0,0 +1,33 @@ +// app/assets/javascripts/application/organization_filter.js +$(function() { + // Manejar cambios en las casillas de organizaciones + $(document).on('change', '.organization-checkbox', function() { + // Obtener valores actuales de la URL + 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'); + + // Mantener parámetros actuales + 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(''); + } + } + + // Enviar el formulario + form.submit(); + }); + }); \ No newline at end of file diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 5a8e3616..4ef33dea 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -6,12 +6,6 @@ class PostsController < ApplicationController def index context = model.active.of_active_members - if current_organization.present? - context = context.where( - organization_id: current_organization.id - ) - end - posts = apply_scopes(context) posts = posts.search_by_query(params[:q]) if params[:q].present? posts = posts.page(params[:page]).per(25) diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb new file mode 100644 index 00000000..47438672 --- /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/models/post.rb b/app/models/post.rb index 199755bf..bf586750 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/views/inquiries/index.html.erb b/app/views/inquiries/index.html.erb index 198dbc77..a72f20fc 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 %>
    \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 1d6cf127..cff6319f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -304,6 +304,7 @@ en: table: actions: Actions to: To + filter_by_organizations: "Filter by organizations" inquiries: edit: submit: Change request diff --git a/config/locales/es.yml b/config/locales/es.yml index 4bad6558..1d0992ae 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -101,8 +101,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 @@ -304,6 +304,7 @@ es: table: actions: Acciones to: a + filter_by_organizations: "Filtrar por organizaciones" inquiries: edit: submit: Cambiar demanda From 40b6fc42329771701b8b3ac910d382c925cb92ca Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 15 Apr 2025 19:56:12 +0200 Subject: [PATCH 06/37] Change name --- config/locales/en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index cff6319f..523385eb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -101,8 +101,8 @@ en: one: Offer other: Offers organization: - one: Time Bank - other: Time Banks + one: Organization + other: Organizations post: one: Post other: Posts From 9249048ff903b55b67221e664bb2bf6ed0f6721f Mon Sep 17 00:00:00 2001 From: gmartincor Date: Wed, 16 Apr 2025 12:43:25 +0200 Subject: [PATCH 07/37] new tests offers visibility --- spec/controllers/offers_controller_spec.rb | 26 ++++++++ .../Offers_organization_filtering_spec.rb | 60 +++++++++++++++++++ spec/models/post_spec.rb | 25 ++++++++ 3 files changed, 111 insertions(+) create mode 100644 spec/features/Offers_organization_filtering_spec.rb diff --git a/spec/controllers/offers_controller_spec.rb b/spec/controllers/offers_controller_spec.rb index 82a1adc9..ca832adb 100644 --- a/spec/controllers/offers_controller_spec.rb +++ b/spec/controllers/offers_controller_spec.rb @@ -52,6 +52,32 @@ expect(assigns(:offers)).to eq([other_offer]) end end + + context "when filtering by organization" do + let(:organization1) { Organization.find_by(name: "Banco de Tiempo Local") } + let(:organization2) { Organization.find_by(name: "El otro Banco de Tiempo :)") } + let(:user1) { User.find_by(email: "user@timeoverflow.org") } + let(:user2) { User.find_by(email: "user2@timeoverflow.org") } + let!(:offer1) { Offer.find_by(title: "Ruby on Rails nivel principiante") || + Fabricate(:offer, user: user1, title: "Ruby on Rails nivel principiante") } + let!(:offer2) { Offer.find_by(title: "Cocina low cost") || + Fabricate(:offer, user: user2, title: "Cocina low cost") } + + before { login(user1) } + + it 'displays only offers from the selected organization' do + get :index, params: { organization_id: organization1.id } + + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).not_to include(offer2) + end + + it 'displays all offers when no organization is selected' do + get :index + + expect(assigns(:offers)).to include(offer1, offer2) + end + end end context "with another organization" do diff --git a/spec/features/Offers_organization_filtering_spec.rb b/spec/features/Offers_organization_filtering_spec.rb new file mode 100644 index 00000000..d909989f --- /dev/null +++ b/spec/features/Offers_organization_filtering_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +RSpec.feature 'Offers organization filtering' do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + let(:category) { Fabricate(:category) } + let(:member) { Fabricate(:member, organization: organization) } + let(:other_member) { Fabricate(:member, organization: other_organization) } + let(:user) { member.user } + + before do + user.terms_accepted_at = Time.current + user.save! + + # Create an accepted alliance + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "accepted" + ) + + # Create posts in both organizations + 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) + + # Log in as user + sign_in_with(user.email, user.password) + end + + scenario 'User filters posts by allied organization' do + visit offers_path + + # Should see posts from both organizations by default + expect(page).to have_content("Local offer") + expect(page).to have_content("Allied offer") + + # Click on the organization dropdown toggle + find('a.dropdown-toggle', text: Organization.model_name.human(count: :other)).click + + # Find the organization in the dropdown menu and click it directly by url + query_params = { org: other_organization.id } + link_path = "#{offers_path}?#{query_params.to_query}" + visit link_path + + # Should see only posts from selected organization + expect(page).to have_content("Allied offer") + expect(page).not_to have_content("Local offer") + end +end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 55ba751c..035e7813 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 From 8e1e9c58e1ed4a17c3fcdd6cf912574fbf07409b Mon Sep 17 00:00:00 2001 From: gmartincor Date: Wed, 16 Apr 2025 18:15:41 +0200 Subject: [PATCH 08/37] new test Offers Visibility --- spec/controllers/offers_controller_spec.rb | 40 +++++++++++-------- .../Offers_organization_filtering_spec.rb | 25 +++++------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/spec/controllers/offers_controller_spec.rb b/spec/controllers/offers_controller_spec.rb index ca832adb..d685744b 100644 --- a/spec/controllers/offers_controller_spec.rb +++ b/spec/controllers/offers_controller_spec.rb @@ -54,39 +54,47 @@ end context "when filtering by organization" do - let(:organization1) { Organization.find_by(name: "Banco de Tiempo Local") } - let(:organization2) { Organization.find_by(name: "El otro Banco de Tiempo :)") } - let(:user1) { User.find_by(email: "user@timeoverflow.org") } - let(:user2) { User.find_by(email: "user2@timeoverflow.org") } - let!(:offer1) { Offer.find_by(title: "Ruby on Rails nivel principiante") || - Fabricate(:offer, user: user1, title: "Ruby on Rails nivel principiante") } - let!(:offer2) { Offer.find_by(title: "Cocina low cost") || - Fabricate(:offer, user: user2, title: "Cocina low cost") } + 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 { login(user1) } + 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: { organization_id: organization1.id } - + get :index, params: { org: organization1.id } expect(assigns(:offers)).to include(offer1) expect(assigns(:offers)).not_to include(offer2) end it 'displays all offers when no organization is selected' do get :index - - expect(assigns(:offers)).to include(offer1, offer2) + expect(assigns(:offers)).to include(offer1) + expect(assigns(:offers)).to include(offer2) 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) + separate_member = Fabricate(:member, organization: separate_organization, user: separate_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/features/Offers_organization_filtering_spec.rb b/spec/features/Offers_organization_filtering_spec.rb index d909989f..fe7ba919 100644 --- a/spec/features/Offers_organization_filtering_spec.rb +++ b/spec/features/Offers_organization_filtering_spec.rb @@ -4,22 +4,24 @@ let(:organization) { Fabricate(:organization) } let(:other_organization) { Fabricate(:organization) } let(:category) { Fabricate(:category) } - let(:member) { Fabricate(:member, organization: organization) } - let(:other_member) { Fabricate(:member, organization: other_organization) } - let(:user) { member.user } - before do - user.terms_accepted_at = Time.current - user.save! + let(:user) do + u = Fabricate(:user, password: "12345test", password_confirmation: "12345test") + u.terms_accepted_at = Time.current + u.save! + u + end - # Create an accepted alliance + 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" ) - # Create posts in both organizations Fabricate(:offer, user: user, organization: organization, @@ -34,26 +36,21 @@ title: "Allied offer", active: true) - # Log in as user - sign_in_with(user.email, user.password) + sign_in_with(user.email, "12345test") end scenario 'User filters posts by allied organization' do visit offers_path - # Should see posts from both organizations by default expect(page).to have_content("Local offer") expect(page).to have_content("Allied offer") - # Click on the organization dropdown toggle find('a.dropdown-toggle', text: Organization.model_name.human(count: :other)).click - # Find the organization in the dropdown menu and click it directly by url query_params = { org: other_organization.id } link_path = "#{offers_path}?#{query_params.to_query}" visit link_path - # Should see only posts from selected organization expect(page).to have_content("Allied offer") expect(page).not_to have_content("Local offer") end From 08a27114c6ffa324280c9d8a2b7d00001f791768 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Thu, 17 Apr 2025 08:59:09 +0200 Subject: [PATCH 09/37] Correc test offers_controller_spec.rb --- spec/controllers/offers_controller_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/controllers/offers_controller_spec.rb b/spec/controllers/offers_controller_spec.rb index d685744b..f47bf4f7 100644 --- a/spec/controllers/offers_controller_spec.rb +++ b/spec/controllers/offers_controller_spec.rb @@ -88,7 +88,6 @@ it "skips the original org's offers" do separate_organization = Fabricate(:organization) separate_user = Fabricate(:user) - separate_member = Fabricate(:member, organization: separate_organization, user: separate_user) login(separate_user) From a35ceabccd35e18a6e4fd6ffc97953469d17ca36 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 18 Apr 2025 14:06:47 +0200 Subject: [PATCH 10/37] Transfer time Between diferents organizations --- app/controllers/offers_controller.rb | 6 +- app/controllers/posts_controller.rb | 13 +++++ app/controllers/transfers_controller.rb | 38 +++++++++++- app/mailers/organization_notifier.rb | 14 +++++ app/models/transfer.rb | 28 ++++++++- app/models/transfer_factory.rb | 58 ++++++++++++------- app/views/inquiries/show.html.erb | 14 ++++- app/views/offers/show.html.erb | 25 +++++++- .../contact_request.html.erb | 26 +++++++++ app/views/shared/_post.html.erb | 12 +++- app/views/transfers/new.html.erb | 23 ++++++-- config/locales/en.yml | 15 +++++ config/locales/es.yml | 15 +++++ config/routes.rb | 11 +++- ...0031_add_cross_bank_fields_to_transfers.rb | 5 ++ 15 files changed, 266 insertions(+), 37 deletions(-) create mode 100644 app/views/organization_notifier/contact_request.html.erb create mode 100644 db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index 232ba52e..e3895f6e 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/posts_controller.rb b/app/controllers/posts_controller.rb index 4ef33dea..906a9334 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -65,6 +65,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_now + 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 e6550656..7098453d 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,28 @@ def delete_reason private + def create_cross_bank_transfer(post) + transfer_factory = TransferFactory.new( + current_organization, + current_user, + post.id, + nil, + true + ) + + transfer = transfer_factory.build_transfer + transfer.amount = transfer_params[:amount] + transfer.reason = transfer_params[:reason] + + persister = ::Persister::TransferPersister.new(transfer) + + if persister.save + redirect_to post, notice: t('transfers.cross_bank.success') + else + redirect_back fallback_location: post, alert: transfer.errors.full_messages.to_sentence + end + end + def find_source if admin? Account.find(transfer_params[:source]) diff --git a/app/mailers/organization_notifier.rb b/app/mailers/organization_notifier.rb index 2625b98e..d2420731 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/transfer.rb b/app/models/transfer.rb index 41e4db8c..967496c1 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -11,7 +11,7 @@ # account, so the total sum of the system is zero # class Transfer < ApplicationRecord - attr_accessor :source, :destination, :amount, :hours, :minutes + attr_accessor :source, :destination, :amount, :hours, :minutes, :is_cross_bank, :meta belongs_to :post, optional: true has_many :movements, dependent: :destroy @@ -23,8 +23,32 @@ class Transfer < ApplicationRecord after_create :make_movements def make_movements + if is_cross_bank && meta.present? + make_cross_bank_movements + else + movements.create(account: Account.find(source_id), amount: -amount.to_i, created_at: created_at) + movements.create(account: Account.find(destination_id), amount: amount.to_i, created_at: created_at) + end + end + + def make_cross_bank_movements + source_organization_id = meta[:source_organization_id] + destination_organization_id = meta[:destination_organization_id] + final_destination_user_id = meta[:final_destination_user_id] + + source_organization = Organization.find(source_organization_id) + destination_organization = Organization.find(destination_organization_id) + final_user = User.find(final_destination_user_id) + final_member = final_user.members.find_by(organization: destination_organization) + movements.create(account: Account.find(source_id), amount: -amount.to_i, created_at: created_at) - movements.create(account: Account.find(destination_id), amount: amount.to_i, created_at: created_at) + movements.create(account: source_organization.account, amount: amount.to_i, created_at: created_at) + + movements.create(account: source_organization.account, amount: -amount.to_i, created_at: created_at) + movements.create(account: destination_organization.account, amount: amount.to_i, created_at: created_at) + + movements.create(account: destination_organization.account, amount: -amount.to_i, created_at: created_at) + movements.create(account: final_member.account, amount: amount.to_i, created_at: created_at) end def source_id diff --git a/app/models/transfer_factory.rb b/app/models/transfer_factory.rb index a093299c..f8962a8b 100644 --- a/app/models/transfer_factory.rb +++ b/app/models/transfer_factory.rb @@ -1,24 +1,41 @@ 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) + if offer_id.present? + Offer.find_by_id(offer_id) + end 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 = Transfer.new(source: source) + + if cross_bank && offer && offer.organization != current_organization + transfer.destination = destination_organization_account.id + transfer.post = offer + transfer.is_cross_bank = true + transfer.meta = { + source_organization_id: current_organization.id, + destination_organization_id: offer.organization.id, + final_destination_user_id: offer.user.id + } + else + transfer.destination = destination_account.id + transfer.post = offer unless for_organization? + end + transfer end @@ -32,49 +49,50 @@ 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.try(:accountable).class == 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 of the target organization for cross-bank transfers # + # @return [Account] + def destination_organization_account + offer.organization.account + end + # Returns the account the time will be transfered to # # @return [Account] 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 + # Get the destination account from the offer's user + member = offer.user.members.find_by(organization: offer.organization) + member.account if member + end end end diff --git a/app/views/inquiries/show.html.erb b/app/views/inquiries/show.html.erb index 637a1dde..b9e88151 100644 --- a/app/views/inquiries/show.html.erb +++ b/app/views/inquiries/show.html.erb @@ -4,5 +4,17 @@ <%= render 'shared/post_actions', post: @inquiry %> <% end %>
    +<% 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/show.html.erb b/app/views/offers/show.html.erb index 787ff858..18ebd012 100644 --- a/app/views/offers/show.html.erb +++ b/app/views/offers/show.html.erb @@ -4,6 +4,12 @@ <%= render 'shared/post_actions', post: @offer %> <% end %> <% if current_user and @offer.user != current_user %> + <% if current_organization != @offer.organization && current_user.active?(current_organization) %> + <%= link_to t('posts.show.request_contact'), + contact_post_path(@offer), + method: :post, + class: "btn btn-info" %> + <% end %> <%= link_to new_transfer_path(id: @offer.user.id, offer: @offer.id, destination_account_id: @destination_account.id), class: "btn btn-success" do %> <%= glyph :time %> @@ -11,5 +17,22 @@ <% end %> <% end %>
    +<% 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_notifier/contact_request.html.erb b/app/views/organization_notifier/contact_request.html.erb new file mode 100644 index 00000000..b10fed86 --- /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/shared/_post.html.erb b/app/views/shared/_post.html.erb index c079f84b..3d1fc3ea 100644 --- a/app/views/shared/_post.html.erb +++ b/app/views/shared/_post.html.erb @@ -69,17 +69,25 @@ -<% 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" %>
    -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb index f4ac301c..a5a1ec1b 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,11 +51,13 @@
    <% 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 %>
    <%= f.button :submit, class: "btn btn-secondary", data: { disable_with: "..." } %>
    -<% end %> +<% end %> \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 523385eb..7aff37d6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -408,6 +408,12 @@ 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} time bank 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 @@ -468,6 +474,12 @@ en: posts: show: info: This %{type} belongs to %{organization}. + contact_info_hidden: "Contact information is not visible as this %{type} is from %{organization} time bank. Click 'Request Contact' 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 @@ -555,6 +567,9 @@ en: other: "%{count} minutes" new: error_amount: Time must be greater than 0 + cross_bank: + info: "This is a time transfer to a member who belongs to another organization. The time will be transferred through both organizations." + success: "Cross-organization transfer completed successfully." users: avatar: change_your_image: Change your image diff --git a/config/locales/es.yml b/config/locales/es.yml index 1d0992ae..62ec6deb 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -408,6 +408,12 @@ es: subject: Boletín semanal text1: 'Últimas ofertas publicadas:' text2: 'Últimas demandas publicadas:' + contact_request: + subject: "Solicitud de contacto para tu %{post}" + greeting: "Hola %{name}," + message: "%{requester} del banco de tiempo %{organization} está interesado/a en tu %{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 @@ -468,6 +474,12 @@ es: posts: show: info: Esta %{type} pertenece a %{organization}. + contact_info_hidden: "La información de contacto no es visible ya que esta %{type} es del banco de tiempo %{organization}. Haz clic en '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 @@ -555,6 +567,9 @@ 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 otra organización. El tiempo se transferirá a través de ambas organizaciones." + success: "Transferencia entre organizaciones completada con éxito." users: avatar: change_your_image: Cambia tu imagen diff --git a/config/routes.rb b/config/routes.rb index 2eba7fd1..a4c72348 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 @@ -103,4 +108,4 @@ match '/404', to: 'errors#not_found', via: :all match '/500', to: 'errors#internal_server_error', via: :all -end +end \ No newline at end of file diff --git a/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb b/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb new file mode 100644 index 00000000..7ed50e8b --- /dev/null +++ b/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb @@ -0,0 +1,5 @@ +class AddCrossBankFieldsToTransfers < ActiveRecord::Migration[7.2] + def change + add_column :transfers, :meta, :jsonb, default: {}, null: false + end +end From 5897a3c1442e8bfa032eae3c9d6aae6e9a2a7a19 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 18 Apr 2025 17:32:34 +0200 Subject: [PATCH 11/37] correct logic model and view --- app/models/transfer.rb | 18 +++++++++++++++ app/views/shared/_movements.html.erb | 33 ++++++++++++++++++---------- config/locales/en.yml | 4 ++-- config/locales/es.yml | 4 ++-- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 967496c1..e237b1a3 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -63,4 +63,22 @@ def different_source_and_destination return unless source == destination errors.add(:base, :same_account) end + + def cross_bank? + movements.count > 2 + end + + def related_account_for(movement) + return nil unless movement.transfer == self + + movements_in_order = movements.order(:id) + current_index = movements_in_order.index(movement) + return nil unless current_index + + if movement.amount > 0 && current_index > 0 + movements_in_order[current_index - 1].account + elsif movement.amount < 0 && current_index < movements_in_order.length - 1 + movements_in_order[current_index + 1].account + end + end end diff --git a/app/views/shared/_movements.html.erb b/app/views/shared/_movements.html.erb index 62fbd588..c0be236d 100644 --- a/app/views/shared/_movements.html.erb +++ b/app/views/shared/_movements.html.erb @@ -20,20 +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 = nil + + if mv.transfer&.cross_bank? + display_account = mv.transfer.related_account_for(mv) + display_account ||= mv.other_side.account + else + display_account = mv.other_side.account + end + %> + + <% 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 %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7aff37d6..e89e3785 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -474,7 +474,7 @@ en: posts: show: info: This %{type} belongs to %{organization}. - contact_info_hidden: "Contact information is not visible as this %{type} is from %{organization} time bank. Click 'Request Contact' to connect with the member." + 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: @@ -568,7 +568,7 @@ en: new: error_amount: Time must be greater than 0 cross_bank: - info: "This is a time transfer to a member who belongs to another organization. The time will be transferred through both organizations." + info: "This is a time transfer to a member who belongs to %{organization}. The time will be transferred through both organizations." success: "Cross-organization transfer completed successfully." users: avatar: diff --git a/config/locales/es.yml b/config/locales/es.yml index 62ec6deb..497dabbf 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -474,7 +474,7 @@ es: posts: show: info: Esta %{type} pertenece a %{organization}. - contact_info_hidden: "La información de contacto no es visible ya que esta %{type} es del banco de tiempo %{organization}. Haz clic en 'Solicitar Contacto' para conectar con el miembro." + 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: @@ -568,7 +568,7 @@ es: new: error_amount: 'El tiempo debe ser mayor que 0 ' cross_bank: - info: "Esta es una transferencia de tiempo a un miembro perteneciente a otra organización. El tiempo se transferirá a través de ambas organizaciones." + 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." users: avatar: From 6a0ab387c23d29e8109abb1de5f0612f7084bdc2 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 18 Apr 2025 17:57:14 +0200 Subject: [PATCH 12/37] correct style --- app/views/shared/_post.html.erb | 6 +++--- app/views/transfers/new.html.erb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/shared/_post.html.erb b/app/views/shared/_post.html.erb index 3d1fc3ea..4e15d318 100644 --- a/app/views/shared/_post.html.erb +++ b/app/views/shared/_post.html.erb @@ -71,13 +71,13 @@ <% 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 %> @@ -85,7 +85,7 @@ <% end %> <% unless current_user %> -
    +
    <%= link_to t("layouts.application.login"), new_user_session_path, class: "btn btn-primary" %> diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb index a5a1ec1b..0f7b4ad6 100644 --- a/app/views/transfers/new.html.erb +++ b/app/views/transfers/new.html.erb @@ -6,7 +6,7 @@ <% if offer %>

    <%= offer %>

    <% if cross_bank %> -
    +
    <%= t 'transfers.cross_bank.info', organization: offer.organization.name %>
    <% end %> From 4721f1ce60c66e45a2ade0fda5d91388d8480b33 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 18 Apr 2025 20:10:38 +0200 Subject: [PATCH 13/37] deliver_later --- app/controllers/posts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 906a9334..13da9611 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -69,7 +69,7 @@ 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_now + OrganizationNotifier.contact_request(@post, current_user, current_organization).deliver_later flash[:notice] = t('posts.contact.success') else flash[:error] = t('posts.contact.error') From ad285fcc89ccba41cad6d09de0c9d3934bf1f365 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sat, 19 Apr 2025 11:40:55 +0200 Subject: [PATCH 14/37] tests --- .../posts_controller_contact_spec.rb | 47 +++++++++++++++++++ .../transfers_controller_cross_bank_spec.rb | 44 +++++++++++++++++ ...anization_notifier_contact_request_spec.rb | 29 ++++++++++++ .../transfer_factory_cross_bank_spec.rb | 37 +++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 spec/controllers/posts_controller_contact_spec.rb create mode 100644 spec/controllers/transfers_controller_cross_bank_spec.rb create mode 100644 spec/mailers/organization_notifier_contact_request_spec.rb create mode 100644 spec/models/transfer_factory_cross_bank_spec.rb diff --git a/spec/controllers/posts_controller_contact_spec.rb b/spec/controllers/posts_controller_contact_spec.rb new file mode 100644 index 00000000..bc16e137 --- /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 00000000..1a468543 --- /dev/null +++ b/spec/controllers/transfers_controller_cross_bank_spec.rb @@ -0,0 +1,44 @@ +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) } + + 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 one transfer and six movements' do + expect { request! }.to change(Transfer, :count).by(1) + .and change(Movement, :count).by(6) + 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 + 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 00000000..3d0598f9 --- /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/transfer_factory_cross_bank_spec.rb b/spec/models/transfer_factory_cross_bank_spec.rb new file mode 100644 index 00000000..c53fc9f9 --- /dev/null +++ b/spec/models/transfer_factory_cross_bank_spec.rb @@ -0,0 +1,37 @@ +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(:offer) { Fabricate(:offer, organization: dest_org) } + + subject(:transfer) do + described_class.new(source_org, current_user, offer.id, nil, true).build_transfer + end + + it 'marks the transfer as cross‑bank' do + expect(transfer.is_cross_bank).to be true + end + + 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 'stores metadata required to rebuild the six‑movement chain' do + expect(transfer.meta).to eq( + source_organization_id: source_org.id, + destination_organization_id: dest_org.id, + final_destination_user_id: offer.user.id + ) + end + + it 'associates the offer as the transfer post' do + expect(transfer.post).to eq(offer) + end + end +end From 58333b63dd46be72e104b842aea7949a038a9b84 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 20 Apr 2025 12:34:47 +0200 Subject: [PATCH 15/37] Correct offers visibility --- app/controllers/posts_controller.rb | 6 ++++++ app/models/organization.rb | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 13da9611..65998274 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -6,6 +6,12 @@ class PostsController < ApplicationController def index context = model.active.of_active_members + if current_user.present? && current_organization.present? + allied_org_ids = current_organization.allied_organizations.pluck(:id) + org_ids = [current_organization.id] + allied_org_ids + context = context.by_organizations(org_ids) + end + posts = apply_scopes(context) posts = posts.search_by_query(params[:q]) if params[:q].present? posts = posts.page(params[:page]).per(25) diff --git a/app/models/organization.rb b/app/models/organization.rb index e2cc5dff..2d15c9ee 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -84,6 +84,12 @@ 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 From a45b9eaa99f25afd82ce5ed4985689897ca5ce45 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 20 Apr 2025 13:01:18 +0200 Subject: [PATCH 16/37] Correc test offers Visibility --- spec/controllers/offers_controller_spec.rb | 118 +++++++++++++-------- 1 file changed, 75 insertions(+), 43 deletions(-) diff --git a/spec/controllers/offers_controller_spec.rb b/spec/controllers/offers_controller_spec.rb index f47bf4f7..c125486d 100644 --- a/spec/controllers/offers_controller_spec.rb +++ b/spec/controllers/offers_controller_spec.rb @@ -22,65 +22,97 @@ 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 + 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") } - 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 + before do + member1 + member2 + login(user1) + Fabricate(:member, user: user1, organization: organization2) unless user1.members.where(organization: organization2).exists? + end - it 'displays all offers when no organization is selected' do - get :index - expect(assigns(:offers)).to include(offer1) - expect(assigns(:offers)).to include(offer2) - 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 and allied organizations 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)).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 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 From 4db2fb6a02b019fdd26eac7fc9ec460fcc3882f7 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 20 Apr 2025 13:22:07 +0200 Subject: [PATCH 17/37] Add cross-bank transfer validation to only allow transfers between allied organizations --- app/models/transfer.rb | 18 ++++++++++++++++++ config/locales/en.yml | 1 + config/locales/es.yml | 1 + 3 files changed, 20 insertions(+) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index e237b1a3..8644b326 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -19,6 +19,7 @@ class Transfer < ApplicationRecord validates :amount, numericality: { greater_than: 0 } validate :different_source_and_destination + validate :validate_organizations_alliance, if: -> { is_cross_bank && meta.present? } after_create :make_movements @@ -81,4 +82,21 @@ def related_account_for(movement) movements_in_order[current_index + 1].account end end + + private + + def validate_organizations_alliance + return unless meta[:source_organization_id] && meta[:destination_organization_id] + + source_org = Organization.find_by(id: meta[:source_organization_id]) + dest_org = Organization.find_by(id: meta[:destination_organization_id]) + + return unless source_org && dest_org + + alliance = source_org.alliance_with(dest_org) + + unless alliance && alliance.accepted? + errors.add(:base, :no_alliance_between_organizations) + end + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index e89e3785..738372fd 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: diff --git a/config/locales/es.yml b/config/locales/es.yml index 497dabbf..ce9c49aa 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: From 4da8396d81d02e22fc032dc0126c30d64d449eb1 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 20 Apr 2025 13:28:54 +0200 Subject: [PATCH 18/37] Update cross-bank transfer tests to include alliance creation --- spec/controllers/transfers_controller_cross_bank_spec.rb | 8 ++++++++ spec/models/transfer_factory_cross_bank_spec.rb | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/spec/controllers/transfers_controller_cross_bank_spec.rb b/spec/controllers/transfers_controller_cross_bank_spec.rb index 1a468543..e24ec78a 100644 --- a/spec/controllers/transfers_controller_cross_bank_spec.rb +++ b/spec/controllers/transfers_controller_cross_bank_spec.rb @@ -13,6 +13,14 @@ 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 diff --git a/spec/models/transfer_factory_cross_bank_spec.rb b/spec/models/transfer_factory_cross_bank_spec.rb index c53fc9f9..7a84b25c 100644 --- a/spec/models/transfer_factory_cross_bank_spec.rb +++ b/spec/models/transfer_factory_cross_bank_spec.rb @@ -6,6 +6,15 @@ let!(:source_member) { Fabricate(:member, user: current_user, organization: source_org) } let(:offer) { Fabricate(:offer, organization: dest_org) } + + let!(:alliance) do + OrganizationAlliance.create!( + source_organization: source_org, + target_organization: dest_org, + status: "accepted" + ) + end + subject(:transfer) do described_class.new(source_org, current_user, offer.id, nil, true).build_transfer end From 4e02352727d85de3fac0de97f3db7eac04f892d1 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 20 Apr 2025 18:05:27 +0200 Subject: [PATCH 19/37] Change default posts visibility to only show current organization's posts --- app/controllers/posts_controller.rb | 4 ++++ app/views/shared/_post_filters.html.erb | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 65998274..ac9723c8 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -7,9 +7,13 @@ def index context = model.active.of_active_members 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) diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb index cf92f46d..40767ed6 100644 --- a/app/views/shared/_post_filters.html.erb +++ b/app/views/shared/_post_filters.html.erb @@ -51,7 +51,14 @@
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb index 0f7b4ad6..e399dc2d 100644 --- a/app/views/transfers/new.html.erb +++ b/app/views/transfers/new.html.erb @@ -60,4 +60,4 @@ <%= f.button :submit, class: "btn btn-secondary", data: { disable_with: "..." } %>
    -<% end %> \ No newline at end of file +<% end %> diff --git a/config/routes.rb b/config/routes.rb index a4c72348..54715059 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,4 +108,4 @@ match '/404', to: 'errors#not_found', via: :all match '/500', to: 'errors#internal_server_error', via: :all -end \ No newline at end of file +end diff --git a/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb b/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb deleted file mode 100644 index 7ed50e8b..00000000 --- a/db/migrate/20250418100031_add_cross_bank_fields_to_transfers.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddCrossBankFieldsToTransfers < ActiveRecord::Migration[7.2] - def change - add_column :transfers, :meta, :jsonb, default: {}, null: false - end -end From 58505051c81f625b743601c6707d0e8ec60beda5 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 25 Apr 2025 08:33:35 +0200 Subject: [PATCH 25/37] refactor code --- .../application/organizations_filter.js | 59 +++++++++---------- .../organization_alliances/index.html.erb | 2 +- .../organizations/_alliance_button.html.erb | 2 +- .../organizations/_organizations_row.html.erb | 2 +- app/views/organizations/index.html.erb | 2 +- app/views/shared/_post_filters.html.erb | 2 +- 6 files changed, 32 insertions(+), 37 deletions(-) diff --git a/app/assets/javascripts/application/organizations_filter.js b/app/assets/javascripts/application/organizations_filter.js index 96414911..0ae1c372 100644 --- a/app/assets/javascripts/application/organizations_filter.js +++ b/app/assets/javascripts/application/organizations_filter.js @@ -1,33 +1,28 @@ -// app/assets/javascripts/application/organization_filter.js $(function() { - // Manejar cambios en las casillas de organizaciones - $(document).on('change', '.organization-checkbox', function() { - // Obtener valores actuales de la URL - 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'); - - // Mantener parámetros actuales - 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(''); - } - } - - // Enviar el formulario - form.submit(); - }); - }); \ No newline at end of file + $(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/views/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb index 8b10b742..2747be62 100644 --- a/app/views/organization_alliances/index.html.erb +++ b/app/views/organization_alliances/index.html.erb @@ -99,4 +99,4 @@
    -
    \ No newline at end of file + diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb index fc7f5d09..6be3d44e 100644 --- a/app/views/organizations/_alliance_button.html.erb +++ b/app/views/organizations/_alliance_button.html.erb @@ -13,4 +13,4 @@ <% elsif alliance.rejected? %> <%= t('organization_alliances.rejected') %> <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/organizations/_organizations_row.html.erb b/app/views/organizations/_organizations_row.html.erb index 1a834b24..30b5a6c5 100644 --- a/app/views/organizations/_organizations_row.html.erb +++ b/app/views/organizations/_organizations_row.html.erb @@ -12,4 +12,4 @@ <%= render "organizations/alliance_button", organization: org %> <% end %> - \ No newline at end of file + diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb index 3860b986..f750148d 100644 --- a/app/views/organizations/index.html.erb +++ b/app/views/organizations/index.html.erb @@ -40,4 +40,4 @@ <%= paginate @organizations %> - \ No newline at end of file + diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb index 40767ed6..daeeccab 100644 --- a/app/views/shared/_post_filters.html.erb +++ b/app/views/shared/_post_filters.html.erb @@ -78,4 +78,4 @@
  • - \ No newline at end of file + From 494d11d97f87892c05f7cc1cd726f55e043ffc7d Mon Sep 17 00:00:00 2001 From: gmartincor Date: Fri, 25 Apr 2025 08:46:58 +0200 Subject: [PATCH 26/37] structure.sql --- db/structure.sql | 169 +++++++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/db/structure.sql b/db/structure.sql index 21304444..4388a527 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,13 +1,25 @@ SET statement_timeout = 0; SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; -SET row_security = off; + +-- +-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; + + +-- +-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; + -- -- Name: hstore; Type: EXTENSION; Schema: -; Owner: - @@ -70,7 +82,7 @@ CREATE FUNCTION public.posts_trigger() RETURNS trigger SET default_tablespace = ''; -SET default_table_access_method = heap; +SET default_with_oids = false; -- -- Name: accounts; Type: TABLE; Schema: public; Owner: - @@ -1289,7 +1301,7 @@ CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING b -- Name: posts tsvectorupdate; Type: TRIGGER; Schema: public; Owner: - -- -CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON public.posts FOR EACH ROW EXECUTE FUNCTION public.posts_trigger(); +CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON public.posts FOR EACH ROW EXECUTE PROCEDURE public.posts_trigger(); -- @@ -1395,79 +1407,78 @@ ALTER TABLE ONLY public.organization_alliances SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES -('20250412110249'), -('20250215163406'), -('20250215163405'), -('20250215163404'), -('20241230170753'), -('20231120164346'), -('20231120164231'), -('20230401114456'), -('20230314233504'), -('20230312231058'), -('20221016192111'), -('20210503201944'), -('20210502160343'), -('20210424174640'), -('20210423193937'), -('20190523225323'), -('20190523213421'), -('20190412163011'), -('20190411192828'), -('20190322180602'), -('20190319121401'), -('20181004200104'), -('20180924164456'), -('20180831161349'), -('20180828160700'), -('20180604145622'), -('20180530180546'), -('20180529144243'), -('20180525141138'), -('20180524143938'), -('20180514193153'), -('20180501093846'), -('20180221161343'), -('20150422162806'), -('20150330200315'), -('20150329193421'), -('20140514225527'), -('20140513141718'), -('20140119161433'), -('20131231110424'), -('20131227155440'), -('20131227142805'), -('20131227110122'), -('20131220160257'), -('20131104032622'), -('20131104013829'), -('20131104013634'), -('20131104004235'), -('20131103221044'), -('20131029202724'), -('20131027215517'), -('20131025202608'), -('20131017144321'), -('20130723160206'), -('20130703234042'), -('20130703234011'), -('20130703233851'), -('20130621105452'), -('20130621103501'), -('20130621103053'), -('20130621102219'), -('20130618210236'), -('20130514094755'), -('20130513092219'), -('20130508085004'), -('20130425165150'), -('20130222185624'), -('20130214181128'), -('20130214175758'), -('20121121233818'), -('20121104085711'), -('20121104004639'), -('20121019101022'), +('1'), ('2'), -('1'); - +('20121019101022'), +('20121104004639'), +('20121104085711'), +('20121121233818'), +('20130214175758'), +('20130214181128'), +('20130222185624'), +('20130425165150'), +('20130508085004'), +('20130513092219'), +('20130514094755'), +('20130618210236'), +('20130621102219'), +('20130621103053'), +('20130621103501'), +('20130621105452'), +('20130703233851'), +('20130703234011'), +('20130703234042'), +('20130723160206'), +('20131017144321'), +('20131025202608'), +('20131027215517'), +('20131029202724'), +('20131103221044'), +('20131104004235'), +('20131104013634'), +('20131104013829'), +('20131104032622'), +('20131220160257'), +('20131227110122'), +('20131227142805'), +('20131227155440'), +('20131231110424'), +('20140119161433'), +('20140513141718'), +('20140514225527'), +('20150329193421'), +('20150330200315'), +('20150422162806'), +('20180221161343'), +('20180501093846'), +('20180514193153'), +('20180524143938'), +('20180525141138'), +('20180529144243'), +('20180530180546'), +('20180604145622'), +('20180828160700'), +('20180831161349'), +('20180924164456'), +('20181004200104'), +('20190319121401'), +('20190322180602'), +('20190411192828'), +('20190412163011'), +('20190523213421'), +('20190523225323'), +('20210423193937'), +('20210424174640'), +('20210502160343'), +('20210503201944'), +('20221016192111'), +('20230312231058'), +('20230314233504'), +('20230401114456'), +('20231120164231'), +('20231120164346'), +('20241230170753'), +('20250215163404'), +('20250215163405'), +('20250215163406'), +('20250412110249'); From ce153171bb6e3d7738e354d324f1e8b7a94bbf1b Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 20 May 2025 10:30:23 +0200 Subject: [PATCH 27/37] interog-direct-transfer --- .../organization_transfers_controller.rb | 58 +++++++++++++++++++ app/helpers/transfers_helper.rb | 15 +++++ app/views/organization_transfers/new.html.erb | 33 +++++++++++ app/views/organizations/show.html.erb | 17 +++++- app/views/shared/_movements.html.erb | 2 + config/locales/en.yml | 13 ++++- config/locales/es.yml | 11 ++++ config/routes.rb | 2 + 8 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 app/controllers/organization_transfers_controller.rb create mode 100644 app/views/organization_transfers/new.html.erb diff --git a/app/controllers/organization_transfers_controller.rb b/app/controllers/organization_transfers_controller.rb new file mode 100644 index 00000000..23ca0b8a --- /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('activerecord.errors.models.transfer.attributes.base.no_alliance_between_organizations') + end + end +end diff --git a/app/helpers/transfers_helper.rb b/app/helpers/transfers_helper.rb index 7e6cf20f..09b7fd11 100644 --- a/app/helpers/transfers_helper.rb +++ b/app/helpers/transfers_helper.rb @@ -22,4 +22,19 @@ def accounts_from_movements(transfer, with_links: false) end end end + + def is_bank_to_bank_transfer?(transfer) + return false unless transfer && transfer.movements.count == 2 + + source_movement = transfer.movements.find_by('amount < 0') + destination_movement = transfer.movements.find_by('amount > 0') + return false unless source_movement && destination_movement + + source_account = source_movement.account + destination_account = destination_movement.account + + source_account.accountable_type == 'Organization' && + destination_account.accountable_type == 'Organization' && + transfer.post.nil? + end end diff --git a/app/views/organization_transfers/new.html.erb b/app/views/organization_transfers/new.html.erb new file mode 100644 index 00000000..946a2209 --- /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/show.html.erb b/app/views/organizations/show.html.erb index 5bfda7a5..9daf1d4d 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 c0be236d..f5a8fa2c 100644 --- a/app/views/shared/_movements.html.erb +++ b/app/views/shared/_movements.html.erb @@ -48,6 +48,8 @@ <% 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/config/locales/en.yml b/config/locales/en.yml index 738372fd..bb782ac6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -427,6 +427,17 @@ en: 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 @@ -637,4 +648,4 @@ en: last: Last next: Next previous: Previous - truncate: Truncate \ No newline at end of file + truncate: Truncate diff --git a/config/locales/es.yml b/config/locales/es.yml index 69a48217..ee2fe8a2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -427,6 +427,17 @@ es: 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 bancos de tiempo" + submit: "Crear transferencia" + description: "Transferir tiempo desde %{source_organization} a %{destination_organization}" + reason_hint: "Opcional: Describe el motivo de esta transferencia entre bancos" + create: + success: "Transferencia entre bancos realizada con éxito" + error: "Error al realizar la transferencia: %{error}" pages: about: app-mobile: App Móvil diff --git a/config/routes.rb b/config/routes.rb index 54715059..e5c46d69 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,6 +40,8 @@ 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 From 435f206ad52799bb13b4c6625406159cbb8a2299 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 20 May 2025 11:30:52 +0200 Subject: [PATCH 28/37] tests --- .../organization_transfers_controller_spec.rb | 161 ++++++++++++++++++ spec/helpers/transfers_helper_spec.rb | 64 +++++++ 2 files changed, 225 insertions(+) create mode 100644 spec/controllers/organization_transfers_controller_spec.rb diff --git a/spec/controllers/organization_transfers_controller_spec.rb b/spec/controllers/organization_transfers_controller_spec.rb new file mode 100644 index 00000000..309cad53 --- /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/helpers/transfers_helper_spec.rb b/spec/helpers/transfers_helper_spec.rb index adb2048a..f37c93db 100644 --- a/spec/helpers/transfers_helper_spec.rb +++ b/spec/helpers/transfers_helper_spec.rb @@ -15,4 +15,68 @@ 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 # 1 hour + ) + # Save the transfer to create the movements + ::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 # With associated 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 From 3b49a9210fa274c9238ec1d1b2b28aa888d4147b Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 20 May 2025 12:05:31 +0200 Subject: [PATCH 29/37] refactor code --- config/locales/en.yml | 2 +- config/locales/es.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index bb782ac6..d660690e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -412,7 +412,7 @@ en: contact_request: subject: "Contact request for your %{post}" greeting: "Hello %{name}," - message: "%{requester} from %{organization} time bank is interested in your %{post}." + 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: diff --git a/config/locales/es.yml b/config/locales/es.yml index ee2fe8a2..58029554 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -431,12 +431,12 @@ es: bank_to_bank_transfer: "Transferir tiempo entre organizaciones" bank_transfer: "Transferencia entre organizaciones" new: - title: "Transferencia entre bancos de tiempo" + 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 entre bancos" + reason_hint: "Opcional: Describe el motivo de esta transferencia organizaciones" create: - success: "Transferencia entre bancos realizada con éxito" + success: "Transferencia entre organizaciones realizada con éxito" error: "Error al realizar la transferencia: %{error}" pages: about: From 9d05f27248312b2a78e778e8c7743e3df576488a Mon Sep 17 00:00:00 2001 From: gmartincor Date: Tue, 20 May 2025 12:48:00 +0200 Subject: [PATCH 30/37] refactor code --- spec/helpers/transfers_helper_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/helpers/transfers_helper_spec.rb b/spec/helpers/transfers_helper_spec.rb index f37c93db..09830d59 100644 --- a/spec/helpers/transfers_helper_spec.rb +++ b/spec/helpers/transfers_helper_spec.rb @@ -27,9 +27,8 @@ transfer = Transfer.new( source: organization1.account, destination: organization2.account, - amount: 60 # 1 hour + amount: 60 ) - # Save the transfer to create the movements ::Persister::TransferPersister.new(transfer).save transfer end @@ -62,7 +61,7 @@ source: organization1.account, destination: organization2.account, amount: 60, - post: post # With associated post + post: post ) ::Persister::TransferPersister.new(transfer).save transfer From a5994e6a0422a4091d7d16bde2cd13f3d0c6cc10 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 15 Jun 2025 17:51:31 +0200 Subject: [PATCH 31/37] refactor code --- .../organization_transfers_controller.rb | 2 +- app/controllers/transfers_controller.rb | 9 ++++ app/models/transfer.rb | 45 +++---------------- config/locales/en.yml | 4 +- config/locales/es.yml | 1 + 5 files changed, 19 insertions(+), 42 deletions(-) diff --git a/app/controllers/organization_transfers_controller.rb b/app/controllers/organization_transfers_controller.rb index 23ca0b8a..f2a18e84 100644 --- a/app/controllers/organization_transfers_controller.rb +++ b/app/controllers/organization_transfers_controller.rb @@ -52,7 +52,7 @@ def validate_alliance alliance = current_organization.alliance_with(destination_organization) unless alliance && alliance.accepted? redirect_to organizations_path, - alert: t('activerecord.errors.models.transfer.attributes.base.no_alliance_between_organizations') + alert: t('transfers.cross_bank.no_alliance') end end end diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 12ae72f9..6c981c87 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -72,6 +72,12 @@ def create_cross_bank_transfer(post) 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 @@ -108,6 +114,9 @@ def create_cross_bank_transfer(post) 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? diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 8644b326..bf664883 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -11,7 +11,7 @@ # account, so the total sum of the system is zero # class Transfer < ApplicationRecord - attr_accessor :source, :destination, :amount, :hours, :minutes, :is_cross_bank, :meta + attr_accessor :source, :destination, :amount, :hours, :minutes, :is_cross_bank belongs_to :post, optional: true has_many :movements, dependent: :destroy @@ -19,37 +19,12 @@ class Transfer < ApplicationRecord validates :amount, numericality: { greater_than: 0 } validate :different_source_and_destination - validate :validate_organizations_alliance, if: -> { is_cross_bank && meta.present? } after_create :make_movements def make_movements - if is_cross_bank && meta.present? - make_cross_bank_movements - else - movements.create(account: Account.find(source_id), amount: -amount.to_i, created_at: created_at) - movements.create(account: Account.find(destination_id), amount: amount.to_i, created_at: created_at) - end - end - - def make_cross_bank_movements - source_organization_id = meta[:source_organization_id] - destination_organization_id = meta[:destination_organization_id] - final_destination_user_id = meta[:final_destination_user_id] - - source_organization = Organization.find(source_organization_id) - destination_organization = Organization.find(destination_organization_id) - final_user = User.find(final_destination_user_id) - final_member = final_user.members.find_by(organization: destination_organization) - movements.create(account: Account.find(source_id), amount: -amount.to_i, created_at: created_at) - movements.create(account: source_organization.account, amount: amount.to_i, created_at: created_at) - - movements.create(account: source_organization.account, amount: -amount.to_i, created_at: created_at) - movements.create(account: destination_organization.account, amount: amount.to_i, created_at: created_at) - - movements.create(account: destination_organization.account, amount: -amount.to_i, created_at: created_at) - movements.create(account: final_member.account, amount: amount.to_i, created_at: created_at) + movements.create(account: Account.find(destination_id), amount: amount.to_i, created_at: created_at) end def source_id @@ -85,18 +60,8 @@ def related_account_for(movement) private - def validate_organizations_alliance - return unless meta[:source_organization_id] && meta[:destination_organization_id] - - source_org = Organization.find_by(id: meta[:source_organization_id]) - dest_org = Organization.find_by(id: meta[:destination_organization_id]) - - return unless source_org && dest_org - - alliance = source_org.alliance_with(dest_org) - - unless alliance && alliance.accepted? - errors.add(:base, :no_alliance_between_organizations) - end + def different_source_and_destination + return unless source == destination + errors.add(:base, :same_account) end end diff --git a/config/locales/en.yml b/config/locales/en.yml index d660690e..dad7aa7b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -580,8 +580,10 @@ en: 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." - success: "Cross-organization transfer completed successfully." users: avatar: change_your_image: Change your image diff --git a/config/locales/es.yml b/config/locales/es.yml index 58029554..400f33c9 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -583,6 +583,7 @@ es: 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: From d118159a5dc4d77e56146e71481c7d28c7d48522 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 15 Jun 2025 18:01:47 +0200 Subject: [PATCH 32/37] refactor --- spec/views/offers/show.html.erb_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/views/offers/show.html.erb_spec.rb b/spec/views/offers/show.html.erb_spec.rb index f886b4d3..c0267ad9 100644 --- a/spec/views/offers/show.html.erb_spec.rb +++ b/spec/views/offers/show.html.erb_spec.rb @@ -91,8 +91,6 @@ assign :offer, offer render template: 'offers/show' - # Verificar que la vista muestra el nombre de la organización - # sin depender del formato exacto del mensaje expect(rendered).to include(offer.organization.name) end end @@ -133,8 +131,6 @@ assign :offer, offer render template: 'offers/show' - # Verificar que la vista muestra el nombre de la organización - # sin depender del formato exacto del mensaje expect(rendered).to include(offer.organization.name) end From ebf20154d53a71e52fe393d3709c910643dc800b Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 15 Jun 2025 19:38:31 +0200 Subject: [PATCH 33/37] refactor --- app/models/transfer.rb | 18 ------------------ app/views/shared/_movements.html.erb | 9 +-------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index bf664883..b385f7be 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -40,24 +40,6 @@ def different_source_and_destination errors.add(:base, :same_account) end - def cross_bank? - movements.count > 2 - end - - def related_account_for(movement) - return nil unless movement.transfer == self - - movements_in_order = movements.order(:id) - current_index = movements_in_order.index(movement) - return nil unless current_index - - if movement.amount > 0 && current_index > 0 - movements_in_order[current_index - 1].account - elsif movement.amount < 0 && current_index < movements_in_order.length - 1 - movements_in_order[current_index + 1].account - end - end - private def different_source_and_destination diff --git a/app/views/shared/_movements.html.erb b/app/views/shared/_movements.html.erb index f5a8fa2c..12959fb3 100644 --- a/app/views/shared/_movements.html.erb +++ b/app/views/shared/_movements.html.erb @@ -21,14 +21,7 @@ <% - display_account = nil - - if mv.transfer&.cross_bank? - display_account = mv.transfer.related_account_for(mv) - display_account ||= mv.other_side.account - else - display_account = mv.other_side.account - end + display_account = mv.other_side.account %> <% if display_account.accountable.present? %> From 3f118db249f4425abaf99a31df39c0b313be7f96 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 15 Jun 2025 20:28:28 +0200 Subject: [PATCH 34/37] refactor --- app/controllers/transfers_controller.rb | 3 +-- app/helpers/transfers_helper.rb | 14 +++++--------- app/models/transfer.rb | 7 +------ app/models/transfer_factory.rb | 17 +++++------------ .../transfers_controller_cross_bank_spec.rb | 12 ++++++++++++ spec/models/transfer_factory_cross_bank_spec.rb | 1 - 6 files changed, 24 insertions(+), 30 deletions(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 6c981c87..b10222da 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -99,8 +99,7 @@ def create_cross_bank_transfer(post) destination: destination_organization.account, amount: transfer_params[:amount], reason: post.description, - post: post, - is_cross_bank: true + post: post ) @persisters << ::Persister::TransferPersister.new(org_to_org_transfer) diff --git a/app/helpers/transfers_helper.rb b/app/helpers/transfers_helper.rb index 09b7fd11..93d27f0c 100644 --- a/app/helpers/transfers_helper.rb +++ b/app/helpers/transfers_helper.rb @@ -24,17 +24,13 @@ def accounts_from_movements(transfer, with_links: false) end def is_bank_to_bank_transfer?(transfer) - return false unless transfer && transfer.movements.count == 2 + return false unless transfer - source_movement = transfer.movements.find_by('amount < 0') - destination_movement = transfer.movements.find_by('amount > 0') - return false unless source_movement && destination_movement + source_account = transfer.movements.find_by('amount < 0')&.account + destination_account = transfer.movements.find_by('amount > 0')&.account - source_account = source_movement.account - destination_account = destination_movement.account - - source_account.accountable_type == 'Organization' && - destination_account.accountable_type == 'Organization' && + source_account&.accountable_type == 'Organization' && + destination_account&.accountable_type == 'Organization' && transfer.post.nil? end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index b385f7be..24c99402 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -11,7 +11,7 @@ # account, so the total sum of the system is zero # class Transfer < ApplicationRecord - attr_accessor :source, :destination, :amount, :hours, :minutes, :is_cross_bank + attr_accessor :source, :destination, :amount, :hours, :minutes belongs_to :post, optional: true has_many :movements, dependent: :destroy @@ -35,11 +35,6 @@ def destination_id destination.respond_to?(:id) ? destination.id : destination end - def different_source_and_destination - return unless source == destination - errors.add(:base, :same_account) - end - private def different_source_and_destination diff --git a/app/models/transfer_factory.rb b/app/models/transfer_factory.rb index 95019e74..e71153be 100644 --- a/app/models/transfer_factory.rb +++ b/app/models/transfer_factory.rb @@ -8,22 +8,15 @@ def initialize(current_organization, current_user, offer_id, destination_account end def offer - if offer_id.present? - Offer.find_by_id(offer_id) - end + @offer ||= Offer.find_by_id(offer_id) if offer_id.present? end def build_transfer transfer = Transfer.new(source: source) + transfer.destination = destination_account.id - if cross_bank && offer && offer.organization != current_organization - transfer.destination = destination_account.id - transfer.post = offer - transfer.is_cross_bank = true - else - transfer.destination = destination_account.id - transfer.post = offer unless for_organization? - end + transfer.post = offer if (cross_bank && offer && offer.organization != current_organization) || + (offer && !for_organization?) transfer end @@ -63,7 +56,7 @@ def source end def for_organization? - destination_account.try(:accountable).class == Organization + destination_account&.accountable.is_a?(Organization) end def admin? diff --git a/spec/controllers/transfers_controller_cross_bank_spec.rb b/spec/controllers/transfers_controller_cross_bank_spec.rb index 24cc99c2..ea1fcd48 100644 --- a/spec/controllers/transfers_controller_cross_bank_spec.rb +++ b/spec/controllers/transfers_controller_cross_bank_spec.rb @@ -48,5 +48,17 @@ 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/models/transfer_factory_cross_bank_spec.rb b/spec/models/transfer_factory_cross_bank_spec.rb index 720a69bc..ea35e071 100644 --- a/spec/models/transfer_factory_cross_bank_spec.rb +++ b/spec/models/transfer_factory_cross_bank_spec.rb @@ -30,7 +30,6 @@ before do allow(transfer_factory).to receive(:destination_account).and_return(dest_org.account) - allow_any_instance_of(Transfer).to receive(:is_cross_bank=) end describe '#build_transfer' do From 6d15d64bfbfde5ed025601efa62acda55dea2e39 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 15 Jun 2025 20:36:17 +0200 Subject: [PATCH 35/37] refactor --- app/models/transfer_factory.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models/transfer_factory.rb b/app/models/transfer_factory.rb index e71153be..36464c0f 100644 --- a/app/models/transfer_factory.rb +++ b/app/models/transfer_factory.rb @@ -12,8 +12,7 @@ def offer end def build_transfer - transfer = Transfer.new(source: source) - transfer.destination = destination_account.id + transfer = Transfer.new(source: source, destination: destination_account.id) transfer.post = offer if (cross_bank && offer && offer.organization != current_organization) || (offer && !for_organization?) From 86d64d0707566453a55ae8ddcb2b46f4e4eeb763 Mon Sep 17 00:00:00 2001 From: gmartincor Date: Sun, 27 Jul 2025 16:32:18 +0200 Subject: [PATCH 36/37] delete comments --- app/views/shared/_post_filters.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb index daeeccab..4cdf22db 100644 --- a/app/views/shared/_post_filters.html.erb +++ b/app/views/shared/_post_filters.html.erb @@ -52,7 +52,6 @@