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