From 098b3b0a2a5893e258b4ced427648890c130c09d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 18 Dec 2025 12:57:44 +0100 Subject: [PATCH] Remove paid field from members Remove paid field from Member resource, database migration, tests, seeds, and UI. This field is no longer needed as payment status is now tracked via membership fee cycles. --- lib/membership/member.ex | 4 - lib/mv/constants.ex | 1 - lib/mv_web/live/member_live/index.ex | 2 +- lib/mv_web/live/member_live/index.html.heex | 8 - lib/mv_web/translations/member_fields.ex | 1 - ...0251218113900_remove_paid_from_members.exs | 21 ++ priv/repo/seeds.exs | 6 - .../repo/custom_fields/20251218113900.json | 132 ++++++++++ .../repo/members/20251218113900.json | 233 ++++++++++++++++++ test/membership/member_test.exs | 9 - test/mv_web/member_live/index_test.exs | 217 ---------------- 11 files changed, 387 insertions(+), 247 deletions(-) create mode 100644 priv/repo/migrations/20251218113900_remove_paid_from_members.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251218113900.json create mode 100644 priv/resource_snapshots/repo/members/20251218113900.json diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5f7df47..0c90f4d 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -509,10 +509,6 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end - attribute :paid, :boolean do - allow_nil? true - end - attribute :phone_number, :string do allow_nil? true end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 843ad2b..c81dbd6 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,7 +7,6 @@ defmodule Mv.Constants do :first_name, :last_name, :email, - :paid, :phone_number, :join_date, :exit_date, diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 822bce6..7ed4007 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -819,7 +819,7 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field?(field) when is_atom(field) do # All member fields are sortable, but we exclude some that don't make sense # :id is not in member_fields, but we don't want to sort by it anyway - non_sortable_fields = [:notes, :paid] + non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 47162d5..5b27e6f 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -307,14 +307,6 @@ > {MvWeb.MemberLive.Index.format_date(member.join_date)} - <:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}> - - {if member.paid == true, do: gettext("Yes"), else: gettext("No")} - - <:col :let={member} label={ diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 3750bcb..f10e0d2 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do def label(:first_name), do: gettext("First Name") def label(:last_name), do: gettext("Last Name") def label(:email), do: gettext("Email") - def label(:paid), do: gettext("Paid") def label(:phone_number), do: gettext("Phone") def label(:join_date), do: gettext("Join Date") def label(:exit_date), do: gettext("Exit Date") diff --git a/priv/repo/migrations/20251218113900_remove_paid_from_members.exs b/priv/repo/migrations/20251218113900_remove_paid_from_members.exs new file mode 100644 index 0000000..3722137 --- /dev/null +++ b/priv/repo/migrations/20251218113900_remove_paid_from_members.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.RemovePaidFromMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:members) do + remove :paid + end + end + + def down do + alter table(:members) do + add :paid, :boolean + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 2e8694d..feb7170 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -134,7 +134,6 @@ for member_attrs <- [ last_name: "Müller", email: "hans.mueller@example.de", join_date: ~D[2023-01-15], - paid: true, phone_number: "+49301234567", city: "München", street: "Hauptstraße", @@ -146,7 +145,6 @@ for member_attrs <- [ last_name: "Schmidt", email: "greta.schmidt@example.de", join_date: ~D[2023-02-01], - paid: false, phone_number: "+49309876543", city: "Hamburg", street: "Lindenstraße", @@ -159,7 +157,6 @@ for member_attrs <- [ last_name: "Wagner", email: "friedrich.wagner@example.de", join_date: ~D[2022-11-10], - paid: true, phone_number: "+49301122334", city: "Berlin", street: "Kastanienallee", @@ -170,7 +167,6 @@ for member_attrs <- [ last_name: "Wagner", email: "marianne.wagner@example.de", join_date: ~D[2022-11-10], - paid: true, phone_number: "+49301122334", city: "Berlin", street: "Kastanienallee", @@ -204,7 +200,6 @@ linked_members = [ last_name: "Weber", email: "maria.weber@example.de", join_date: ~D[2023-03-15], - paid: true, phone_number: "+49301357924", city: "Frankfurt", street: "Goetheplatz", @@ -219,7 +214,6 @@ linked_members = [ last_name: "Klein", email: "thomas.klein@example.de", join_date: ~D[2023-04-01], - paid: false, phone_number: "+49302468135", city: "Köln", street: "Rheinstraße", diff --git a/priv/resource_snapshots/repo/custom_fields/20251218113900.json b/priv/resource_snapshots/repo/custom_fields/20251218113900.json new file mode 100644 index 0000000..b8fee81 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251218113900.json @@ -0,0 +1,132 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "show_in_overview", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "6FEA699A67D34CFBA261DA8316AB711F6853C4F953D42C5D7940B22D17699B2E", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/members/20251218113900.json b/priv/resource_snapshots/repo/members/20251218113900.json new file mode 100644 index 0000000..96ad143 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20251218113900.json @@ -0,0 +1,233 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "membership_fee_start_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "members_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E18E4B404581EFF050F85E895FAE986B79DB62C9E1611164C92B46B954C371C1", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 653a2d4..40a68ac 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do @valid_attrs %{ first_name: "John", last_name: "Doe", - paid: true, email: "john@example.com", phone_number: "+49123456789", join_date: ~D[2020-01-01], @@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do assert error_message(errors, :email) =~ "is not a valid email" end - test "Paid is optional but must be boolean if specified" do - attrs = Map.put(@valid_attrs, :paid, nil) - attrs2 = Map.put(@valid_attrs, :paid, "yes") - assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid)) - assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2) - assert error_message(errors, :paid) =~ "is invalid" - end - test "Phone number is optional but must have a valid format if specified" do attrs = Map.put(@valid_attrs, :phone_number, "abc") assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 3232cc0..60bf2aa 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -456,221 +456,4 @@ defmodule MvWeb.MemberLive.IndexTest do assert has_element?(view, "#flash-group") end end - - describe "payment filter integration" do - setup do - # Create members with different payment status - # Use unique names that won't appear elsewhere in the HTML - {:ok, paid_member} = - Mv.Membership.create_member(%{ - first_name: "Zahler", - last_name: "Mitglied", - email: "zahler@example.com", - paid: true - }) - - {:ok, unpaid_member} = - Mv.Membership.create_member(%{ - first_name: "Nichtzahler", - last_name: "Mitglied", - email: "nichtzahler@example.com", - paid: false - }) - - {:ok, nil_paid_member} = - Mv.Membership.create_member(%{ - first_name: "Unbestimmt", - last_name: "Mitglied", - email: "unbestimmt@example.com" - # paid is nil by default - }) - - %{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member} - end - - test "filter shows all members when no filter is active", %{ - conn: conn, - paid_member: paid_member, - unpaid_member: unpaid_member, - nil_paid_member: nil_paid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - assert html =~ paid_member.first_name - assert html =~ unpaid_member.first_name - assert html =~ nil_paid_member.first_name - end - - test "filter shows only paid members when paid filter is active", %{ - conn: conn, - paid_member: paid_member, - unpaid_member: unpaid_member, - nil_paid_member: nil_paid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?paid_filter=paid") - - assert html =~ paid_member.first_name - refute html =~ unpaid_member.first_name - refute html =~ nil_paid_member.first_name - end - - test "filter shows only unpaid members (including nil) when not_paid filter is active", %{ - conn: conn, - paid_member: paid_member, - unpaid_member: unpaid_member, - nil_paid_member: nil_paid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?paid_filter=not_paid") - - refute html =~ paid_member.first_name - assert html =~ unpaid_member.first_name - assert html =~ nil_paid_member.first_name - end - - test "filter combines with search query (AND)", %{ - conn: conn, - paid_member: paid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid") - - assert html =~ paid_member.first_name - end - - test "filter combines with sorting", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - {:ok, view, _html} = - live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc") - - # Click on email sort header - view - |> element("[data-testid='email']") - |> render_click() - - # Filter should be preserved in URL - path = assert_patch(view) - assert path =~ "paid_filter=paid" - assert path =~ "sort_field=email" - end - - test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open filter dropdown - view - |> element("#payment-filter button[aria-haspopup='true']") - |> render_click() - - # Select "Paid" option - view - |> element("#payment-filter button[phx-value-filter='paid']") - |> render_click() - - path = assert_patch(view) - assert path =~ "paid_filter=paid" - end - - test "URL parameter is correctly read on page load", %{ - conn: conn, - paid_member: paid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?paid_filter=paid") - - # Only paid member should be visible - assert html =~ paid_member.first_name - # Filter badge should be visible - assert html =~ "badge" - end - - test "invalid URL parameter is ignored", %{ - conn: conn, - paid_member: paid_member, - unpaid_member: unpaid_member - } do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value") - - # All members should be visible (filter not applied) - assert html =~ paid_member.first_name - assert html =~ unpaid_member.first_name - end - - test "search maintains filter state", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?paid_filter=paid") - - # Perform search - view - |> element("[data-testid='search-input']") - |> render_change(%{"query" => "test"}) - - # Filter state should be maintained in URL - path = assert_patch(view) - assert path =~ "paid_filter=paid" - end - end - - describe "paid column in table" do - setup do - {:ok, paid_member} = - Mv.Membership.create_member(%{ - first_name: "Paid", - last_name: "Member", - email: "paid.column@example.com", - paid: true - }) - - {:ok, unpaid_member} = - Mv.Membership.create_member(%{ - first_name: "Unpaid", - last_name: "Member", - email: "unpaid.column@example.com", - paid: false - }) - - %{paid_member: paid_member, unpaid_member: unpaid_member} - end - - test "paid column shows green badge for paid members", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # Check for success badge (green) - assert html =~ "badge-success" - end - - test "paid column shows red badge for unpaid members", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # Check for error badge (red) - assert html =~ "badge-error" - end - - test "paid column shows 'Yes' for paid members", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - - # The table should contain "Yes" text inside badge - assert html =~ "badge-success" - assert html =~ "Yes" - end - - test "paid column shows 'No' for unpaid members", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - - # The table should contain "No" text inside badge - assert html =~ "badge-error" - assert html =~ "No" - end - end end