From 68b5e8302ec8f0544ae0f0f73b40401a0719803f Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Tue, 25 Aug 2026 16:08:04 -0400 Subject: [PATCH 01/21] Add CommunityFridge data model Introduces the CommunityFridge schema, migration, and test fixtures as the first step of the community fridges feature. --- .../locations/community_fridge.ex | 33 ++++++++ lib/bike_brigade/locations/location.ex | 2 + ...0825183237_add_community_fridges_table.exs | 16 ++++ .../locations/community_fridge_test.exs | 80 +++++++++++++++++++ test/support/fixtures.ex | 32 ++++++++ 5 files changed, 163 insertions(+) create mode 100644 lib/bike_brigade/locations/community_fridge.ex create mode 100644 priv/repo/migrations/20260825183237_add_community_fridges_table.exs create mode 100644 test/bike_brigade/locations/community_fridge_test.exs diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex new file mode 100644 index 00000000..7bb8cc62 --- /dev/null +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -0,0 +1,33 @@ +defmodule BikeBrigade.Locations.CommunityFridge do + use BikeBrigade.Schema + import Ecto.Changeset + + alias BikeBrigade.Locations.Location + + @fields [ + :name, + :description, + :photo, + :active, + :pair_preferred + ] + + schema "community_fridges" do + field :name, :string + field :description, :string + field :photo, :string + field :active, :boolean, default: true + field :pair_preferred, :boolean, default: false + + belongs_to :location, Location + + timestamps() + end + + def changeset(community_fridge, attrs) do + community_fridge + |> cast(attrs, @fields ++ [:location_id]) + |> validate_required([:name]) + |> assoc_constraint(:location) + end +end diff --git a/lib/bike_brigade/locations/location.ex b/lib/bike_brigade/locations/location.ex index e9eb6cb2..7ae0f2c3 100644 --- a/lib/bike_brigade/locations/location.ex +++ b/lib/bike_brigade/locations/location.ex @@ -4,6 +4,7 @@ defmodule BikeBrigade.Locations.Location do alias BikeBrigade.Geocoder alias BikeBrigade.Locations.LocationNeighborhood + alias BikeBrigade.Locations.CommunityFridge alias BikeBrigade.Riders.Rider alias BikeBrigade.Delivery.{Campaign, Opportunity, Task} @@ -29,6 +30,7 @@ defmodule BikeBrigade.Locations.Location do has_one :task_dropoff, Task, foreign_key: :dropoff_location_id, on_delete: :nilify_all has_one :task_pickup, Task, foreign_key: :pickup_location_id, on_delete: :nilify_all has_one :opportunity, Opportunity, on_delete: :nilify_all + has_one :community_fridge, CommunityFridge, on_delete: :nilify_all timestamps() end diff --git a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs new file mode 100644 index 00000000..ea421f9f --- /dev/null +++ b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs @@ -0,0 +1,16 @@ +defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do + use Ecto.Migration + + def change do + create table(:community_fridges) do + add :name, :string + add :description, :text + add :photo, :string + add :active, :boolean, default: true, null: false + add :pair_preferred, :boolean, default: false, null: false + add :location_id, references(:locations, on_delete: :nilify_all) + + timestamps() + end + end +end diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs new file mode 100644 index 00000000..e4c86ca8 --- /dev/null +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -0,0 +1,80 @@ +defmodule BikeBrigade.Locations.CommunityFridgeTest do + use BikeBrigade.DataCase + + alias BikeBrigade.Locations.CommunityFridge + + setup do + location_data = fixture(:location) + + {:ok, location} = + %BikeBrigade.Locations.Location{} + |> BikeBrigade.Locations.Location.changeset(location_data) + |> Repo.insert() + + %{location: location} + end + + describe "CommunityFridge changeset" do + test "changeset with valid attributes", %{location: location} do + attrs = %{ + name: "Downtown Community Fridge", + description: "A fridge for the community", + photo: "https://example.com/photo.jpg", + active: true, + pair_preferred: false, + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert changeset.valid? + end + + test "changeset with minimal required attributes", %{location: location} do + attrs = %{ + name: "Community Fridge", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert changeset.valid? + end + + test "changeset requires name", %{location: location} do + attrs = %{ + description: "A fridge without a name", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "changeset rejects empty name", %{location: location} do + attrs = %{ + name: "", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "changeset sets default values for active and pair_preferred", %{location: location} do + attrs = %{ + name: "Fridge with Defaults", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert Ecto.Changeset.get_field(changeset, :active) == true + assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 4581f4b2..05dcab70 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -186,6 +186,38 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end + def fixture(:community_fridge, attrs) do + # Ensure we have a location_id + attrs = + attrs + |> Map.put_new_lazy(:location_id, fn -> + location_data = fixture(:location) + + {:ok, location} = + %BikeBrigade.Locations.Location{} + |> BikeBrigade.Locations.Location.changeset(location_data) + |> Repo.insert() + + location.id + end) + + {:ok, fridge} = + %BikeBrigade.Locations.CommunityFridge{} + |> BikeBrigade.Locations.CommunityFridge.changeset( + %{ + name: "Community Fridge #{System.unique_integer([:positive])}", + description: "A community fridge for the neighborhood", + active: true, + pair_preferred: false + } + |> Map.merge(attrs) + ) + |> Repo.insert() + + fridge + |> Repo.preload(:location) + end + def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From be328c45fd558925e0f704c670f6884fc5c1554c Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 07:32:37 -0400 Subject: [PATCH 02/21] Fix parameter name in changeset function for CommunityFridge module --- lib/bike_brigade/locations/community_fridge.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index 7bb8cc62..da532257 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -24,9 +24,9 @@ defmodule BikeBrigade.Locations.CommunityFridge do timestamps() end - def changeset(community_fridge, attrs) do + def changeset(community_fridge, params) do community_fridge - |> cast(attrs, @fields ++ [:location_id]) + |> cast(params, @fields ++ [:location_id]) |> validate_required([:name]) |> assoc_constraint(:location) end From e841246cbe47ad71e769acf9075337a1c67c1437 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 11:57:25 -0400 Subject: [PATCH 03/21] Community Fridge CRUD operation added --- lib/bike_brigade/locations.ex | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/bike_brigade/locations.ex b/lib/bike_brigade/locations.ex index 214dc3f5..e4568a08 100644 --- a/lib/bike_brigade/locations.ex +++ b/lib/bike_brigade/locations.ex @@ -1,6 +1,9 @@ defmodule BikeBrigade.Locations do + import Ecto.Query, warn: false + alias BikeBrigade.Repo alias BikeBrigade.Locations.Location + alias BikeBrigade.Locations.CommunityFridge def neighborhood(%Location{} = location) do if neighborhood = Repo.preload(location, :neighborhood).neighborhood do @@ -13,4 +16,50 @@ defmodule BikeBrigade.Locations do def neighborhood(nil) do "Unknown" end + + @doc """ + Returns the list of community_fridges. + """ + def list_community_fridges do + Repo.all(from cf in CommunityFridge, order_by: [desc: cf.active, asc: cf.name]) + end + + @doc """ + Gets a single community_fridge. + + Raises `Ecto.NoResultsError` if the Community fridge does not exist. + """ + def get_community_fridge!(id), do: Repo.get!(CommunityFridge, id) + + @doc """ + Creates a community_fridge. + """ + def create_community_fridge(attrs \\ %{}) do + %CommunityFridge{} + |> CommunityFridge.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a community_fridge. + """ + def update_community_fridge(%CommunityFridge{} = community_fridge, attrs) do + community_fridge + |> CommunityFridge.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a community_fridge. + """ + def delete_community_fridge(%CommunityFridge{} = community_fridge) do + Repo.delete(community_fridge) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking community_fridge changes. + """ + def change_community_fridge(%CommunityFridge{} = community_fridge, attrs \\ %{}) do + CommunityFridge.changeset(community_fridge, attrs) + end end From 32365d65f4b6833b329c47b47c705448c2096e53 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 12:14:08 -0400 Subject: [PATCH 04/21] Addressed the review comments --- lib/bike_brigade/locations/community_fridge.ex | 2 +- .../migrations/20260825183237_add_community_fridges_table.exs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index da532257..208092ab 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -27,7 +27,7 @@ defmodule BikeBrigade.Locations.CommunityFridge do def changeset(community_fridge, params) do community_fridge |> cast(params, @fields ++ [:location_id]) - |> validate_required([:name]) + |> validate_required([:name, :location_id]) |> assoc_constraint(:location) end end diff --git a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs index ea421f9f..14a97a67 100644 --- a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs +++ b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs @@ -3,7 +3,7 @@ defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do def change do create table(:community_fridges) do - add :name, :string + add :name, :string, null: false add :description, :text add :photo, :string add :active, :boolean, default: true, null: false @@ -12,5 +12,7 @@ defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do timestamps() end + + create unique_index(:community_fridges, [:location_id]) end end From f38a1bc1dcce55dc35afb40e54c6413e1a31d97c Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Thu, 27 Aug 2026 14:42:13 -0400 Subject: [PATCH 05/21] Refactor CommunityFridgeTest to use alias for Location and update test descriptions --- .../locations/community_fridge_test.exs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs index e4c86ca8..44395d11 100644 --- a/test/bike_brigade/locations/community_fridge_test.exs +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -1,14 +1,15 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do use BikeBrigade.DataCase + alias BikeBrigade.Locations.Location alias BikeBrigade.Locations.CommunityFridge setup do location_data = fixture(:location) {:ok, location} = - %BikeBrigade.Locations.Location{} - |> BikeBrigade.Locations.Location.changeset(location_data) + %Location{} + |> Location.changeset(location_data) |> Repo.insert() %{location: location} @@ -30,7 +31,7 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do assert changeset.valid? end - test "changeset with minimal required attributes", %{location: location} do + test "changeset with minimal required attributes and default values", %{location: location} do attrs = %{ name: "Community Fridge", location_id: location.id @@ -39,6 +40,8 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) assert changeset.valid? + assert Ecto.Changeset.get_field(changeset, :active) == true + assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false end test "changeset requires name", %{location: location} do @@ -64,17 +67,5 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do refute changeset.valid? assert "can't be blank" in errors_on(changeset).name end - - test "changeset sets default values for active and pair_preferred", %{location: location} do - attrs = %{ - name: "Fridge with Defaults", - location_id: location.id - } - - changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) - - assert Ecto.Changeset.get_field(changeset, :active) == true - assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false - end end end From 3c70e8065119df471b44a1e0c56a8d13a5613a7c Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Fri, 28 Aug 2026 07:56:31 -0400 Subject: [PATCH 06/21] Defer community fridge location fixture improvement to separate PR The cubic-dev review suggested supporting a `location:` key in fixture(:community_fridge, ...) so callers can pass a location struct directly, matching the pattern used by rider and campaign fixtures. Implementing this requires fixture(:location) to persist a Location record and return a struct with an id, rather than returning a plain map as it does today. That change affects every fixture and test that currently calls fixture(:location), so the blast radius is wider than this PR's scope. The improvement will be handled in a follow-up PR dedicated to normalising the location fixture across the test suite. --- test/support/fixtures.ex | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 05dcab70..4581f4b2 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -186,38 +186,6 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end - def fixture(:community_fridge, attrs) do - # Ensure we have a location_id - attrs = - attrs - |> Map.put_new_lazy(:location_id, fn -> - location_data = fixture(:location) - - {:ok, location} = - %BikeBrigade.Locations.Location{} - |> BikeBrigade.Locations.Location.changeset(location_data) - |> Repo.insert() - - location.id - end) - - {:ok, fridge} = - %BikeBrigade.Locations.CommunityFridge{} - |> BikeBrigade.Locations.CommunityFridge.changeset( - %{ - name: "Community Fridge #{System.unique_integer([:positive])}", - description: "A community fridge for the neighborhood", - active: true, - pair_preferred: false - } - |> Map.merge(attrs) - ) - |> Repo.insert() - - fridge - |> Repo.preload(:location) - end - def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From 6226e4273e548f6be4a2a6997715a33402a8be50 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 31 Aug 2026 13:50:40 -0400 Subject: [PATCH 07/21] Enforce one community fridge per location via unique_constraint Add unique_constraint(:location_id) to CommunityFridge.changeset/2 so a location cannot be associated with more than one community fridge. The has_one relationship does not enforce cardinality at the database level, so this constraint surfaces duplicate-location errors as changeset errors. Also trim the minimal-attributes changeset test to focus on required fields rather than default values. --- lib/bike_brigade/locations/community_fridge.ex | 1 + test/bike_brigade/locations/community_fridge_test.exs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index 208092ab..fb00feba 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -28,6 +28,7 @@ defmodule BikeBrigade.Locations.CommunityFridge do community_fridge |> cast(params, @fields ++ [:location_id]) |> validate_required([:name, :location_id]) + |> unique_constraint(:location_id) |> assoc_constraint(:location) end end diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs index 44395d11..c8b3dc46 100644 --- a/test/bike_brigade/locations/community_fridge_test.exs +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -31,7 +31,7 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do assert changeset.valid? end - test "changeset with minimal required attributes and default values", %{location: location} do + test "changeset with minimal required attributes", %{location: location} do attrs = %{ name: "Community Fridge", location_id: location.id @@ -40,8 +40,6 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) assert changeset.valid? - assert Ecto.Changeset.get_field(changeset, :active) == true - assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false end test "changeset requires name", %{location: location} do From be26bd26b22f85e6a63cd7d128ca8cbeabf8853b Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 31 Aug 2026 15:25:34 -0400 Subject: [PATCH 08/21] Add community fridge fixtures and comprehensive test coverage - Add `persisted_location` fixture that inserts a Location into the database (complements existing in-memory `location` fixture) - Add `community_fridge` fixture that lazily creates a persisted location and handles required location_id association - Add LocationsTest with full coverage of community_fridge operations - Use setup block to eliminate fixture repetition across tests - Use pin operator (^) in pattern matches to validate update attrs without assertion repetition - Enable async: true for parallel test execution --- test/bike_brigade/locations_test.exs | 118 +++++++++++++++++++++++++++ test/support/fixtures.ex | 24 +++++- 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 test/bike_brigade/locations_test.exs diff --git a/test/bike_brigade/locations_test.exs b/test/bike_brigade/locations_test.exs new file mode 100644 index 00000000..3e647d18 --- /dev/null +++ b/test/bike_brigade/locations_test.exs @@ -0,0 +1,118 @@ +defmodule BikeBrigade.LocationsTest do + use BikeBrigade.DataCase, async: true + + alias BikeBrigade.Locations + alias BikeBrigade.Locations.CommunityFridge + + describe "community_fridges" do + @invalid_attrs %{active: nil, name: nil, description: nil, photo: nil, pair_preferred: nil} + + setup do + %{community_fridge: fixture(:community_fridge)} + end + + test "list_community_fridges/0 returns all community_fridges", %{ + community_fridge: community_fridge + } do + assert community_fridge.id in Enum.map(Locations.list_community_fridges(), & &1.id) + end + + test "get_community_fridge!/1 returns the community_fridge with given id", %{ + community_fridge: community_fridge + } do + assert Locations.get_community_fridge!(community_fridge.id) == community_fridge + end + + test "create_community_fridge/1 with valid data creates a community_fridge" do + location = fixture(:persisted_location, %{}) + + valid_attrs = %{ + active: true, + name: "some name", + description: "some description", + photo: "some photo", + pair_preferred: true, + location_id: location.id + } + + assert {:ok, %CommunityFridge{} = community_fridge} = + Locations.create_community_fridge(valid_attrs) + + assert community_fridge.active == true + assert community_fridge.name == "some name" + assert community_fridge.description == "some description" + assert community_fridge.photo == "some photo" + assert community_fridge.pair_preferred == true + end + + test "create_community_fridge/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Locations.create_community_fridge(@invalid_attrs) + end + + test "update_community_fridge/2 with valid data updates the community_fridge", %{ + community_fridge: community_fridge + } do + active = false + name = "some updated name" + description = "some updated description" + photo = "some updated photo" + pair_preferred = false + + update_attrs = %{ + active: active, + name: name, + description: description, + photo: photo, + pair_preferred: pair_preferred + } + + assert {:ok, + %CommunityFridge{ + active: ^active, + name: ^name, + description: ^description, + photo: ^photo, + pair_preferred: ^pair_preferred + }} = Locations.update_community_fridge(community_fridge, update_attrs) + end + + test "update_community_fridge/2 with invalid data returns error changeset", %{ + community_fridge: community_fridge + } do + assert {:error, %Ecto.Changeset{}} = + Locations.update_community_fridge(community_fridge, @invalid_attrs) + + assert community_fridge == Locations.get_community_fridge!(community_fridge.id) + end + + test "delete_community_fridge/1 deletes the community_fridge", %{ + community_fridge: community_fridge + } do + assert {:ok, %CommunityFridge{}} = Locations.delete_community_fridge(community_fridge) + + assert_raise Ecto.NoResultsError, fn -> + Locations.get_community_fridge!(community_fridge.id) + end + end + + test "change_community_fridge/1 returns a community_fridge changeset", %{ + community_fridge: community_fridge + } do + assert %Ecto.Changeset{} = Locations.change_community_fridge(community_fridge) + end + end + + describe "community_fridges ordering" do + test "list_community_fridges/0 returns active fridges first then alphabetically by name" do + inactive = fixture(:community_fridge, %{name: "Alpha", active: false}) + active_b = fixture(:community_fridge, %{name: "Beta", active: true}) + active_a = fixture(:community_fridge, %{name: "Alpha", active: true}) + + assert Enum.map(Locations.list_community_fridges(), & &1.id) == [ + active_a.id, + active_b.id, + inactive.id + ] + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 4581f4b2..e392f86e 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,5 +1,5 @@ defmodule BikeBrigade.Fixtures do - alias BikeBrigade.{Accounts, Delivery, Riders, Messaging, Notifications, Repo} + alias BikeBrigade.{Accounts, Delivery, Locations, Riders, Messaging, Notifications, Repo} alias BikeBrigade.Repo.Seeds.Toronto alias BikeBrigade.Repo.Seeds.Toronto @@ -186,6 +186,28 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end + def fixture(:persisted_location, attrs) do + %Locations.Location{} + |> Locations.Location.changeset(Map.merge(Toronto.random_location(), attrs)) + |> Repo.insert!() + end + + def fixture(:community_fridge, attrs) do + attrs = + attrs + |> Map.put_new_lazy(:location_id, fn -> fixture(:persisted_location, %{}).id end) + + {:ok, community_fridge} = + %{ + name: Faker.Company.name(), + active: true + } + |> Map.merge(attrs) + |> Locations.create_community_fridge() + + community_fridge + end + def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From a4f815f9a24ebdcf111a2a91a0cc09794848d7ac Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 14 Sep 2026 14:03:00 -0400 Subject: [PATCH 09/21] Add Community Fridge dispatcher UI to dispatchers only. Changes: - Add CommunityFridgeLive.Index and FormComponent with routes for /community_fridges, /community_fridges/new, and /community_fridges/:id/edit - Add "Community Fridges" entry to the sidebar navigation (archive_box icon) - Relax CommunityFridge changeset to not require location_id at the UI level (name is the only required field; location association remains constrained at the DB level via assoc_constraint) - Add LiveView tests covering list, create, validate, edit, direct navigation, and access control (non-dispatchers redirected to /login) --- .../locations/community_fridge.ex | 2 +- lib/bike_brigade_web/components/layouts.ex | 9 ++ .../community_fridge_live/form_component.ex | 55 +++++++++++ .../form_component.html.heex | 34 +++++++ .../live/community_fridge_live/index.ex | 40 ++++++++ .../community_fridge_live/index.html.heex | 47 ++++++++++ lib/bike_brigade_web/router.ex | 4 + .../live/community_fridge_live_test.exs | 93 +++++++++++++++++++ 8 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 lib/bike_brigade_web/live/community_fridge_live/form_component.ex create mode 100644 lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex create mode 100644 lib/bike_brigade_web/live/community_fridge_live/index.ex create mode 100644 lib/bike_brigade_web/live/community_fridge_live/index.html.heex create mode 100644 test/bike_brigade_web/live/community_fridge_live_test.exs diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index fb00feba..c801e49e 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -27,7 +27,7 @@ defmodule BikeBrigade.Locations.CommunityFridge do def changeset(community_fridge, params) do community_fridge |> cast(params, @fields ++ [:location_id]) - |> validate_required([:name, :location_id]) + |> validate_required([:name]) |> unique_constraint(:location_id) |> assoc_constraint(:location) end diff --git a/lib/bike_brigade_web/components/layouts.ex b/lib/bike_brigade_web/components/layouts.ex index 7771b87d..17f6db13 100644 --- a/lib/bike_brigade_web/components/layouts.ex +++ b/lib/bike_brigade_web/components/layouts.ex @@ -69,6 +69,15 @@ defmodule BikeBrigadeWeb.Layouts do Riders + <.sidebar_link + selected={@current_page == :community_fridges} + navigate={~p"/community_fridges"} + > + <:icon> + + + Community Fridges + <.sidebar_link selected={@current_page == :stats} navigate={~p"/stats"}> <:icon> diff --git a/lib/bike_brigade_web/live/community_fridge_live/form_component.ex b/lib/bike_brigade_web/live/community_fridge_live/form_component.ex new file mode 100644 index 00000000..e63130fd --- /dev/null +++ b/lib/bike_brigade_web/live/community_fridge_live/form_component.ex @@ -0,0 +1,55 @@ +defmodule BikeBrigadeWeb.CommunityFridgeLive.FormComponent do + use BikeBrigadeWeb, :live_component + + alias BikeBrigade.Locations + + @impl true + def update(%{community_fridge: community_fridge} = assigns, socket) do + changeset = Locations.change_community_fridge(community_fridge) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate", %{"community_fridge" => params}, socket) do + changeset = + socket.assigns.community_fridge + |> Locations.change_community_fridge(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + def handle_event("save", %{"community_fridge" => params}, socket) do + save_community_fridge(socket, socket.assigns.action, params) + end + + defp save_community_fridge(socket, :edit, params) do + case Locations.update_community_fridge(socket.assigns.community_fridge, params) do + {:ok, _community_fridge} -> + {:noreply, + socket + |> put_flash(:info, "Community fridge updated successfully") + |> push_navigate(to: socket.assigns.navigate)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_community_fridge(socket, :new, params) do + case Locations.create_community_fridge(params) do + {:ok, _community_fridge} -> + {:noreply, + socket + |> put_flash(:info, "Community fridge created successfully") + |> push_navigate(to: socket.assigns.navigate)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end +end diff --git a/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex new file mode 100644 index 00000000..fbd9064e --- /dev/null +++ b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex @@ -0,0 +1,34 @@ +
+ <.header>{@title} + <.simple_form + :let={f} + for={@changeset} + id="community-fridge-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input type="text" field={f[:name]} label="Name" /> + + <.input type="textarea" field={f[:description]} label="Description" /> + + <.input type="text" field={f[:photo]} label="Photo URL" /> + + <.input + type="checkbox" + field={f[:active]} + label="Active" + /> + + <.input + type="checkbox" + field={f[:pair_preferred]} + label="Pair Preferred" + help_text="Deliveries to this fridge prefer a rider pair" + /> + + <:actions> + <.button type="submit" phx-disable-with="Saving...">Save + + +
diff --git a/lib/bike_brigade_web/live/community_fridge_live/index.ex b/lib/bike_brigade_web/live/community_fridge_live/index.ex new file mode 100644 index 00000000..2e3433b3 --- /dev/null +++ b/lib/bike_brigade_web/live/community_fridge_live/index.ex @@ -0,0 +1,40 @@ +defmodule BikeBrigadeWeb.CommunityFridgeLive.Index do + use BikeBrigadeWeb, :live_view + + alias BikeBrigade.Locations + alias BikeBrigade.Locations.CommunityFridge + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page, :community_fridges) + |> assign(:page_title, "Community Fridges") + |> assign(:community_fridge, nil) + |> assign(:community_fridges, Locations.list_community_fridges())} + end + + @impl Phoenix.LiveView + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Community Fridges") + |> assign(:community_fridge, nil) + |> assign(:community_fridges, Locations.list_community_fridges()) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Fridge") + |> assign(:community_fridge, %CommunityFridge{}) + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Fridge") + |> assign(:community_fridge, Locations.get_community_fridge!(id)) + end +end diff --git a/lib/bike_brigade_web/live/community_fridge_live/index.html.heex b/lib/bike_brigade_web/live/community_fridge_live/index.html.heex new file mode 100644 index 00000000..dd8e8ee9 --- /dev/null +++ b/lib/bike_brigade_web/live/community_fridge_live/index.html.heex @@ -0,0 +1,47 @@ +<.header> + Community Fridges + <:subtitle> + Community fridges are neighbourhood food-sharing locations. Dispatchers can manage fridge details here. + + +
+ <.button patch={~p"/community_fridges/new"}> + New Community Fridge + +
+<.table id="community_fridges" rows={@community_fridges}> + <:col :let={community_fridge} label="Name"> + {community_fridge.name} + + <:col :let={community_fridge} label="Description"> + {community_fridge.description} + + <:col :let={community_fridge} label="Pair Preferred"> + {if community_fridge.pair_preferred, do: "Yes", else: "No"} + + <:col :let={community_fridge} label="Active"> + {if community_fridge.active, do: "Yes", else: "No"} + + + <:action :let={community_fridge}> + <.link patch={~p"/community_fridges/#{community_fridge.id}/edit"} class="link"> + Edit + + + + +<.modal + :if={@live_action in [:new, :edit]} + id="community-fridge-modal" + show + on_cancel={JS.navigate(~p"/community_fridges")} +> + <.live_component + module={BikeBrigadeWeb.CommunityFridgeLive.FormComponent} + id={@community_fridge.id || :new} + action={@live_action} + title={@page_title} + community_fridge={@community_fridge} + navigate={~p"/community_fridges"} + /> + diff --git a/lib/bike_brigade_web/router.ex b/lib/bike_brigade_web/router.ex index aa288e6a..3080f591 100644 --- a/lib/bike_brigade_web/router.ex +++ b/lib/bike_brigade_web/router.ex @@ -180,6 +180,10 @@ defmodule BikeBrigadeWeb.Router do live "/tags/:id/edit", TagLive.Index, :edit live "/delivery_notes", DeliveryNoteLive.Index, :index + + live "/community_fridges", CommunityFridgeLive.Index, :index + live "/community_fridges/new", CommunityFridgeLive.Index, :new + live "/community_fridges/:id/edit", CommunityFridgeLive.Index, :edit end get "/stats/leaderboard/download", ExportStatsController, :leaderboard diff --git a/test/bike_brigade_web/live/community_fridge_live_test.exs b/test/bike_brigade_web/live/community_fridge_live_test.exs new file mode 100644 index 00000000..3a172463 --- /dev/null +++ b/test/bike_brigade_web/live/community_fridge_live_test.exs @@ -0,0 +1,93 @@ +defmodule BikeBrigadeWeb.CommunityFridgeLiveTest do + use BikeBrigadeWeb.ConnCase + import Phoenix.LiveViewTest + + alias BikeBrigade.Locations + + describe "Community Fridges index (dispatcher)" do + setup [:login] + + test "lists all community fridges", ctx do + fridge = fixture(:community_fridge, %{name: "Test Fridge"}) + {:ok, _view, html} = live(ctx.conn, ~p"/community_fridges") + + assert html =~ "Community Fridges" + assert html =~ fridge.name + end + + test "can create a new community fridge", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/community_fridges") + + view |> element("a", "New Community Fridge") |> render_click() + assert_patched(view, ~p"/community_fridges/new") + + {:ok, _view, html} = + view + |> form("#community-fridge-form", community_fridge: %{name: "Brand New Fridge"}) + |> render_submit() + |> follow_redirect(ctx.conn) + + assert html =~ "Community fridge created successfully" + assert html =~ "Brand New Fridge" + end + + test "validates name is required", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/community_fridges/new") + + html = + view + |> form("#community-fridge-form", community_fridge: %{name: ""}) + |> render_submit() + + assert html =~ "can't be blank" + end + + test "can edit a community fridge", ctx do + fridge = fixture(:community_fridge, %{name: "Original Fridge"}) + {:ok, view, _html} = live(ctx.conn, ~p"/community_fridges") + + view |> element("a", "Edit") |> render_click() + assert_patched(view, ~p"/community_fridges/#{fridge.id}/edit") + + {:ok, _view, html} = + view + |> form("#community-fridge-form", community_fridge: %{name: "Updated Fridge"}) + |> render_submit() + |> follow_redirect(ctx.conn) + + assert html =~ "Community fridge updated successfully" + assert html =~ "Updated Fridge" + refute html =~ "Original Fridge" + end + + test "can navigate directly to edit page", ctx do + fridge = fixture(:community_fridge, %{name: "Edit Me"}) + {:ok, _view, html} = live(ctx.conn, ~p"/community_fridges/#{fridge.id}/edit") + + assert html =~ "Edit Fridge" + end + + test "edit preserves existing values", ctx do + fridge = + fixture(:community_fridge, %{ + name: "Preserve Me", + description: "Some description", + active: true, + pair_preferred: true + }) + + {:ok, _view, html} = live(ctx.conn, ~p"/community_fridges/#{fridge.id}/edit") + + assert html =~ "Preserve Me" + assert html =~ "Some description" + end + end + + describe "Community Fridges access control" do + setup [:login_as_rider] + + test "non-dispatchers cannot access community fridges page", ctx do + assert {:error, {:redirect, %{to: "/login"}}} = live(ctx.conn, ~p"/community_fridges") + end + end +end From ea5e3ceff3b645121e5bfdea8ce6286dd3bd0343 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 14 Sep 2026 15:12:01 -0400 Subject: [PATCH 10/21] Add Cancel button to community fridge form actions --- .../live/community_fridge_live/form_component.html.heex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex index fbd9064e..714bdf14 100644 --- a/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex +++ b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex @@ -28,6 +28,9 @@ /> <:actions> + <.button type="button" navigate={~p"/community_fridges"} color={:white}> + Cancel + <.button type="submit" phx-disable-with="Saving...">Save From 14d55d967f7655fe2952c36984e5fa27c7e35637 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 14 Sep 2026 19:54:01 -0400 Subject: [PATCH 11/21] Add cancel button to community fridge form --- .../live/community_fridge_live/form_component.html.heex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex index 714bdf14..7741fd21 100644 --- a/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex +++ b/lib/bike_brigade_web/live/community_fridge_live/form_component.html.heex @@ -28,7 +28,7 @@ /> <:actions> - <.button type="button" navigate={~p"/community_fridges"} color={:white}> + <.button navigate={~p"/community_fridges"} color={:white}> Cancel <.button type="submit" phx-disable-with="Saving...">Save From d3a250953dd1674405213a65a91d8094cd5acb53 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Tue, 25 Aug 2026 16:08:04 -0400 Subject: [PATCH 12/21] Add CommunityFridge data model Introduces the CommunityFridge schema, migration, and test fixtures as the first step of the community fridges feature. --- .../locations/community_fridge.ex | 33 ++++++++ lib/bike_brigade/locations/location.ex | 2 + ...0825183237_add_community_fridges_table.exs | 16 ++++ .../locations/community_fridge_test.exs | 80 +++++++++++++++++++ test/support/fixtures.ex | 32 ++++++++ 5 files changed, 163 insertions(+) create mode 100644 lib/bike_brigade/locations/community_fridge.ex create mode 100644 priv/repo/migrations/20260825183237_add_community_fridges_table.exs create mode 100644 test/bike_brigade/locations/community_fridge_test.exs diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex new file mode 100644 index 00000000..7bb8cc62 --- /dev/null +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -0,0 +1,33 @@ +defmodule BikeBrigade.Locations.CommunityFridge do + use BikeBrigade.Schema + import Ecto.Changeset + + alias BikeBrigade.Locations.Location + + @fields [ + :name, + :description, + :photo, + :active, + :pair_preferred + ] + + schema "community_fridges" do + field :name, :string + field :description, :string + field :photo, :string + field :active, :boolean, default: true + field :pair_preferred, :boolean, default: false + + belongs_to :location, Location + + timestamps() + end + + def changeset(community_fridge, attrs) do + community_fridge + |> cast(attrs, @fields ++ [:location_id]) + |> validate_required([:name]) + |> assoc_constraint(:location) + end +end diff --git a/lib/bike_brigade/locations/location.ex b/lib/bike_brigade/locations/location.ex index e9eb6cb2..7ae0f2c3 100644 --- a/lib/bike_brigade/locations/location.ex +++ b/lib/bike_brigade/locations/location.ex @@ -4,6 +4,7 @@ defmodule BikeBrigade.Locations.Location do alias BikeBrigade.Geocoder alias BikeBrigade.Locations.LocationNeighborhood + alias BikeBrigade.Locations.CommunityFridge alias BikeBrigade.Riders.Rider alias BikeBrigade.Delivery.{Campaign, Opportunity, Task} @@ -29,6 +30,7 @@ defmodule BikeBrigade.Locations.Location do has_one :task_dropoff, Task, foreign_key: :dropoff_location_id, on_delete: :nilify_all has_one :task_pickup, Task, foreign_key: :pickup_location_id, on_delete: :nilify_all has_one :opportunity, Opportunity, on_delete: :nilify_all + has_one :community_fridge, CommunityFridge, on_delete: :nilify_all timestamps() end diff --git a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs new file mode 100644 index 00000000..ea421f9f --- /dev/null +++ b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs @@ -0,0 +1,16 @@ +defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do + use Ecto.Migration + + def change do + create table(:community_fridges) do + add :name, :string + add :description, :text + add :photo, :string + add :active, :boolean, default: true, null: false + add :pair_preferred, :boolean, default: false, null: false + add :location_id, references(:locations, on_delete: :nilify_all) + + timestamps() + end + end +end diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs new file mode 100644 index 00000000..e4c86ca8 --- /dev/null +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -0,0 +1,80 @@ +defmodule BikeBrigade.Locations.CommunityFridgeTest do + use BikeBrigade.DataCase + + alias BikeBrigade.Locations.CommunityFridge + + setup do + location_data = fixture(:location) + + {:ok, location} = + %BikeBrigade.Locations.Location{} + |> BikeBrigade.Locations.Location.changeset(location_data) + |> Repo.insert() + + %{location: location} + end + + describe "CommunityFridge changeset" do + test "changeset with valid attributes", %{location: location} do + attrs = %{ + name: "Downtown Community Fridge", + description: "A fridge for the community", + photo: "https://example.com/photo.jpg", + active: true, + pair_preferred: false, + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert changeset.valid? + end + + test "changeset with minimal required attributes", %{location: location} do + attrs = %{ + name: "Community Fridge", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert changeset.valid? + end + + test "changeset requires name", %{location: location} do + attrs = %{ + description: "A fridge without a name", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "changeset rejects empty name", %{location: location} do + attrs = %{ + name: "", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "changeset sets default values for active and pair_preferred", %{location: location} do + attrs = %{ + name: "Fridge with Defaults", + location_id: location.id + } + + changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) + + assert Ecto.Changeset.get_field(changeset, :active) == true + assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 4581f4b2..05dcab70 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -186,6 +186,38 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end + def fixture(:community_fridge, attrs) do + # Ensure we have a location_id + attrs = + attrs + |> Map.put_new_lazy(:location_id, fn -> + location_data = fixture(:location) + + {:ok, location} = + %BikeBrigade.Locations.Location{} + |> BikeBrigade.Locations.Location.changeset(location_data) + |> Repo.insert() + + location.id + end) + + {:ok, fridge} = + %BikeBrigade.Locations.CommunityFridge{} + |> BikeBrigade.Locations.CommunityFridge.changeset( + %{ + name: "Community Fridge #{System.unique_integer([:positive])}", + description: "A community fridge for the neighborhood", + active: true, + pair_preferred: false + } + |> Map.merge(attrs) + ) + |> Repo.insert() + + fridge + |> Repo.preload(:location) + end + def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From 25b84a3838defaef54663b393cf3604b8350b22b Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 07:32:37 -0400 Subject: [PATCH 13/21] Fix parameter name in changeset function for CommunityFridge module --- lib/bike_brigade/locations/community_fridge.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index 7bb8cc62..da532257 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -24,9 +24,9 @@ defmodule BikeBrigade.Locations.CommunityFridge do timestamps() end - def changeset(community_fridge, attrs) do + def changeset(community_fridge, params) do community_fridge - |> cast(attrs, @fields ++ [:location_id]) + |> cast(params, @fields ++ [:location_id]) |> validate_required([:name]) |> assoc_constraint(:location) end From 727cb30b891d400598f55c75787e63c6446d40fe Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 11:57:25 -0400 Subject: [PATCH 14/21] Community Fridge CRUD operation added --- lib/bike_brigade/locations.ex | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/bike_brigade/locations.ex b/lib/bike_brigade/locations.ex index 214dc3f5..e4568a08 100644 --- a/lib/bike_brigade/locations.ex +++ b/lib/bike_brigade/locations.ex @@ -1,6 +1,9 @@ defmodule BikeBrigade.Locations do + import Ecto.Query, warn: false + alias BikeBrigade.Repo alias BikeBrigade.Locations.Location + alias BikeBrigade.Locations.CommunityFridge def neighborhood(%Location{} = location) do if neighborhood = Repo.preload(location, :neighborhood).neighborhood do @@ -13,4 +16,50 @@ defmodule BikeBrigade.Locations do def neighborhood(nil) do "Unknown" end + + @doc """ + Returns the list of community_fridges. + """ + def list_community_fridges do + Repo.all(from cf in CommunityFridge, order_by: [desc: cf.active, asc: cf.name]) + end + + @doc """ + Gets a single community_fridge. + + Raises `Ecto.NoResultsError` if the Community fridge does not exist. + """ + def get_community_fridge!(id), do: Repo.get!(CommunityFridge, id) + + @doc """ + Creates a community_fridge. + """ + def create_community_fridge(attrs \\ %{}) do + %CommunityFridge{} + |> CommunityFridge.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a community_fridge. + """ + def update_community_fridge(%CommunityFridge{} = community_fridge, attrs) do + community_fridge + |> CommunityFridge.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a community_fridge. + """ + def delete_community_fridge(%CommunityFridge{} = community_fridge) do + Repo.delete(community_fridge) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking community_fridge changes. + """ + def change_community_fridge(%CommunityFridge{} = community_fridge, attrs \\ %{}) do + CommunityFridge.changeset(community_fridge, attrs) + end end From e3f38132fd885d49ad32ee95249c30660fbca0d2 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 26 Aug 2026 12:14:08 -0400 Subject: [PATCH 15/21] Addressed the review comments --- lib/bike_brigade/locations/community_fridge.ex | 2 +- .../migrations/20260825183237_add_community_fridges_table.exs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index da532257..208092ab 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -27,7 +27,7 @@ defmodule BikeBrigade.Locations.CommunityFridge do def changeset(community_fridge, params) do community_fridge |> cast(params, @fields ++ [:location_id]) - |> validate_required([:name]) + |> validate_required([:name, :location_id]) |> assoc_constraint(:location) end end diff --git a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs index ea421f9f..14a97a67 100644 --- a/priv/repo/migrations/20260825183237_add_community_fridges_table.exs +++ b/priv/repo/migrations/20260825183237_add_community_fridges_table.exs @@ -3,7 +3,7 @@ defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do def change do create table(:community_fridges) do - add :name, :string + add :name, :string, null: false add :description, :text add :photo, :string add :active, :boolean, default: true, null: false @@ -12,5 +12,7 @@ defmodule BikeBrigade.Repo.Migrations.AddCommunityFridgesTable do timestamps() end + + create unique_index(:community_fridges, [:location_id]) end end From 58ee025cdb7b3783c3a5a16b8af722057581d2d4 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Thu, 27 Aug 2026 14:42:13 -0400 Subject: [PATCH 16/21] Refactor CommunityFridgeTest to use alias for Location and update test descriptions --- .../locations/community_fridge_test.exs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs index e4c86ca8..44395d11 100644 --- a/test/bike_brigade/locations/community_fridge_test.exs +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -1,14 +1,15 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do use BikeBrigade.DataCase + alias BikeBrigade.Locations.Location alias BikeBrigade.Locations.CommunityFridge setup do location_data = fixture(:location) {:ok, location} = - %BikeBrigade.Locations.Location{} - |> BikeBrigade.Locations.Location.changeset(location_data) + %Location{} + |> Location.changeset(location_data) |> Repo.insert() %{location: location} @@ -30,7 +31,7 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do assert changeset.valid? end - test "changeset with minimal required attributes", %{location: location} do + test "changeset with minimal required attributes and default values", %{location: location} do attrs = %{ name: "Community Fridge", location_id: location.id @@ -39,6 +40,8 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) assert changeset.valid? + assert Ecto.Changeset.get_field(changeset, :active) == true + assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false end test "changeset requires name", %{location: location} do @@ -64,17 +67,5 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do refute changeset.valid? assert "can't be blank" in errors_on(changeset).name end - - test "changeset sets default values for active and pair_preferred", %{location: location} do - attrs = %{ - name: "Fridge with Defaults", - location_id: location.id - } - - changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) - - assert Ecto.Changeset.get_field(changeset, :active) == true - assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false - end end end From 36eeb0776f400ee137d0cd36954bded576513e2b Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Fri, 28 Aug 2026 07:56:31 -0400 Subject: [PATCH 17/21] Defer community fridge location fixture improvement to separate PR The cubic-dev review suggested supporting a `location:` key in fixture(:community_fridge, ...) so callers can pass a location struct directly, matching the pattern used by rider and campaign fixtures. Implementing this requires fixture(:location) to persist a Location record and return a struct with an id, rather than returning a plain map as it does today. That change affects every fixture and test that currently calls fixture(:location), so the blast radius is wider than this PR's scope. The improvement will be handled in a follow-up PR dedicated to normalising the location fixture across the test suite. --- test/support/fixtures.ex | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 05dcab70..4581f4b2 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -186,38 +186,6 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end - def fixture(:community_fridge, attrs) do - # Ensure we have a location_id - attrs = - attrs - |> Map.put_new_lazy(:location_id, fn -> - location_data = fixture(:location) - - {:ok, location} = - %BikeBrigade.Locations.Location{} - |> BikeBrigade.Locations.Location.changeset(location_data) - |> Repo.insert() - - location.id - end) - - {:ok, fridge} = - %BikeBrigade.Locations.CommunityFridge{} - |> BikeBrigade.Locations.CommunityFridge.changeset( - %{ - name: "Community Fridge #{System.unique_integer([:positive])}", - description: "A community fridge for the neighborhood", - active: true, - pair_preferred: false - } - |> Map.merge(attrs) - ) - |> Repo.insert() - - fridge - |> Repo.preload(:location) - end - def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From 34cd1c7f062836e13555472ea2db9c6ec5d8456a Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 31 Aug 2026 13:50:40 -0400 Subject: [PATCH 18/21] Enforce one community fridge per location via unique_constraint Add unique_constraint(:location_id) to CommunityFridge.changeset/2 so a location cannot be associated with more than one community fridge. The has_one relationship does not enforce cardinality at the database level, so this constraint surfaces duplicate-location errors as changeset errors. Also trim the minimal-attributes changeset test to focus on required fields rather than default values. --- lib/bike_brigade/locations/community_fridge.ex | 1 + test/bike_brigade/locations/community_fridge_test.exs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/bike_brigade/locations/community_fridge.ex b/lib/bike_brigade/locations/community_fridge.ex index 208092ab..fb00feba 100644 --- a/lib/bike_brigade/locations/community_fridge.ex +++ b/lib/bike_brigade/locations/community_fridge.ex @@ -28,6 +28,7 @@ defmodule BikeBrigade.Locations.CommunityFridge do community_fridge |> cast(params, @fields ++ [:location_id]) |> validate_required([:name, :location_id]) + |> unique_constraint(:location_id) |> assoc_constraint(:location) end end diff --git a/test/bike_brigade/locations/community_fridge_test.exs b/test/bike_brigade/locations/community_fridge_test.exs index 44395d11..c8b3dc46 100644 --- a/test/bike_brigade/locations/community_fridge_test.exs +++ b/test/bike_brigade/locations/community_fridge_test.exs @@ -31,7 +31,7 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do assert changeset.valid? end - test "changeset with minimal required attributes and default values", %{location: location} do + test "changeset with minimal required attributes", %{location: location} do attrs = %{ name: "Community Fridge", location_id: location.id @@ -40,8 +40,6 @@ defmodule BikeBrigade.Locations.CommunityFridgeTest do changeset = CommunityFridge.changeset(%CommunityFridge{}, attrs) assert changeset.valid? - assert Ecto.Changeset.get_field(changeset, :active) == true - assert Ecto.Changeset.get_field(changeset, :pair_preferred) == false end test "changeset requires name", %{location: location} do From 8849b2b3c1f75cc99a7ecc8d3f343a49915b56cd Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Mon, 31 Aug 2026 15:25:34 -0400 Subject: [PATCH 19/21] Add community fridge fixtures and comprehensive test coverage - Add `persisted_location` fixture that inserts a Location into the database (complements existing in-memory `location` fixture) - Add `community_fridge` fixture that lazily creates a persisted location and handles required location_id association - Add LocationsTest with full coverage of community_fridge operations - Use setup block to eliminate fixture repetition across tests - Use pin operator (^) in pattern matches to validate update attrs without assertion repetition - Enable async: true for parallel test execution --- test/bike_brigade/locations_test.exs | 118 +++++++++++++++++++++++++++ test/support/fixtures.ex | 24 +++++- 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 test/bike_brigade/locations_test.exs diff --git a/test/bike_brigade/locations_test.exs b/test/bike_brigade/locations_test.exs new file mode 100644 index 00000000..3e647d18 --- /dev/null +++ b/test/bike_brigade/locations_test.exs @@ -0,0 +1,118 @@ +defmodule BikeBrigade.LocationsTest do + use BikeBrigade.DataCase, async: true + + alias BikeBrigade.Locations + alias BikeBrigade.Locations.CommunityFridge + + describe "community_fridges" do + @invalid_attrs %{active: nil, name: nil, description: nil, photo: nil, pair_preferred: nil} + + setup do + %{community_fridge: fixture(:community_fridge)} + end + + test "list_community_fridges/0 returns all community_fridges", %{ + community_fridge: community_fridge + } do + assert community_fridge.id in Enum.map(Locations.list_community_fridges(), & &1.id) + end + + test "get_community_fridge!/1 returns the community_fridge with given id", %{ + community_fridge: community_fridge + } do + assert Locations.get_community_fridge!(community_fridge.id) == community_fridge + end + + test "create_community_fridge/1 with valid data creates a community_fridge" do + location = fixture(:persisted_location, %{}) + + valid_attrs = %{ + active: true, + name: "some name", + description: "some description", + photo: "some photo", + pair_preferred: true, + location_id: location.id + } + + assert {:ok, %CommunityFridge{} = community_fridge} = + Locations.create_community_fridge(valid_attrs) + + assert community_fridge.active == true + assert community_fridge.name == "some name" + assert community_fridge.description == "some description" + assert community_fridge.photo == "some photo" + assert community_fridge.pair_preferred == true + end + + test "create_community_fridge/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Locations.create_community_fridge(@invalid_attrs) + end + + test "update_community_fridge/2 with valid data updates the community_fridge", %{ + community_fridge: community_fridge + } do + active = false + name = "some updated name" + description = "some updated description" + photo = "some updated photo" + pair_preferred = false + + update_attrs = %{ + active: active, + name: name, + description: description, + photo: photo, + pair_preferred: pair_preferred + } + + assert {:ok, + %CommunityFridge{ + active: ^active, + name: ^name, + description: ^description, + photo: ^photo, + pair_preferred: ^pair_preferred + }} = Locations.update_community_fridge(community_fridge, update_attrs) + end + + test "update_community_fridge/2 with invalid data returns error changeset", %{ + community_fridge: community_fridge + } do + assert {:error, %Ecto.Changeset{}} = + Locations.update_community_fridge(community_fridge, @invalid_attrs) + + assert community_fridge == Locations.get_community_fridge!(community_fridge.id) + end + + test "delete_community_fridge/1 deletes the community_fridge", %{ + community_fridge: community_fridge + } do + assert {:ok, %CommunityFridge{}} = Locations.delete_community_fridge(community_fridge) + + assert_raise Ecto.NoResultsError, fn -> + Locations.get_community_fridge!(community_fridge.id) + end + end + + test "change_community_fridge/1 returns a community_fridge changeset", %{ + community_fridge: community_fridge + } do + assert %Ecto.Changeset{} = Locations.change_community_fridge(community_fridge) + end + end + + describe "community_fridges ordering" do + test "list_community_fridges/0 returns active fridges first then alphabetically by name" do + inactive = fixture(:community_fridge, %{name: "Alpha", active: false}) + active_b = fixture(:community_fridge, %{name: "Beta", active: true}) + active_a = fixture(:community_fridge, %{name: "Alpha", active: true}) + + assert Enum.map(Locations.list_community_fridges(), & &1.id) == [ + active_a.id, + active_b.id, + inactive.id + ] + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 4581f4b2..e392f86e 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,5 +1,5 @@ defmodule BikeBrigade.Fixtures do - alias BikeBrigade.{Accounts, Delivery, Riders, Messaging, Notifications, Repo} + alias BikeBrigade.{Accounts, Delivery, Locations, Riders, Messaging, Notifications, Repo} alias BikeBrigade.Repo.Seeds.Toronto alias BikeBrigade.Repo.Seeds.Toronto @@ -186,6 +186,28 @@ defmodule BikeBrigade.Fixtures do Toronto.random_location() end + def fixture(:persisted_location, attrs) do + %Locations.Location{} + |> Locations.Location.changeset(Map.merge(Toronto.random_location(), attrs)) + |> Repo.insert!() + end + + def fixture(:community_fridge, attrs) do + attrs = + attrs + |> Map.put_new_lazy(:location_id, fn -> fixture(:persisted_location, %{}).id end) + + {:ok, community_fridge} = + %{ + name: Faker.Company.name(), + active: true + } + |> Map.merge(attrs) + |> Locations.create_community_fridge() + + community_fridge + end + def fixture(:sms_message_from_rider, rider), do: fixture(:sms_message_from_rider, rider, %{}) From b77e3072ac4d2ba9bd52d743b1758d4f64d3b56f Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Wed, 7 Oct 2026 15:41:12 -0400 Subject: [PATCH 20/21] Fix redundant DB query and register :community_fridges page in layout attr - Remove duplicate list_community_fridges/0 call from apply_action(:index) since mount/3 already loads the list on every mount - Add :community_fridges to the current_page attr values list in layouts.ex --- lib/bike_brigade_web/components/layouts.ex | 1 + lib/bike_brigade_web/live/community_fridge_live/index.ex | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bike_brigade_web/components/layouts.ex b/lib/bike_brigade_web/components/layouts.ex index 17f6db13..9685aa26 100644 --- a/lib/bike_brigade_web/components/layouts.ex +++ b/lib/bike_brigade_web/components/layouts.ex @@ -32,6 +32,7 @@ defmodule BikeBrigadeWeb.Layouts do :campaigns, :opportunities, :riders, + :community_fridges, :stats, :users, :messages, diff --git a/lib/bike_brigade_web/live/community_fridge_live/index.ex b/lib/bike_brigade_web/live/community_fridge_live/index.ex index 2e3433b3..ac341c8a 100644 --- a/lib/bike_brigade_web/live/community_fridge_live/index.ex +++ b/lib/bike_brigade_web/live/community_fridge_live/index.ex @@ -23,7 +23,6 @@ defmodule BikeBrigadeWeb.CommunityFridgeLive.Index do socket |> assign(:page_title, "Community Fridges") |> assign(:community_fridge, nil) - |> assign(:community_fridges, Locations.list_community_fridges()) end defp apply_action(socket, :new, _params) do From c4e8ee5c0996ec59f5cd484457c2187f1b14acb5 Mon Sep 17 00:00:00 2001 From: Neel Vinoth Date: Fri, 9 Oct 2026 00:11:49 -0400 Subject: [PATCH 21/21] Remove unused alias for Locations in CommunityFridgeLiveTest --- test/bike_brigade_web/live/community_fridge_live_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/bike_brigade_web/live/community_fridge_live_test.exs b/test/bike_brigade_web/live/community_fridge_live_test.exs index 3a172463..8248e1f0 100644 --- a/test/bike_brigade_web/live/community_fridge_live_test.exs +++ b/test/bike_brigade_web/live/community_fridge_live_test.exs @@ -2,8 +2,6 @@ defmodule BikeBrigadeWeb.CommunityFridgeLiveTest do use BikeBrigadeWeb.ConnCase import Phoenix.LiveViewTest - alias BikeBrigade.Locations - describe "Community Fridges index (dispatcher)" do setup [:login]