From abfc94473f376e6a086c28ccfad8b5b350caa2a9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 11 Jun 2025 22:14:42 +0200 Subject: [PATCH 1/6] Member fields --- Justfile | 1 + lib/membership/member.ex | 181 +++++++++++++++++ lib/membership/property_type.ex | 2 +- mix.exs | 3 +- mix.lock | 4 + .../20250617090641_member_fields.exs | 45 +++++ priv/repo/seeds.exs | 18 +- .../repo/members/20250617090641.json | 187 ++++++++++++++++++ test/membership/member_test.exs | 110 +++++++++++ 9 files changed, 540 insertions(+), 11 deletions(-) create mode 100644 priv/repo/migrations/20250617090641_member_fields.exs create mode 100644 priv/resource_snapshots/repo/members/20250617090641.json create mode 100644 test/membership/member_test.exs diff --git a/Justfile b/Justfile index f44be78..4480991 100644 --- a/Justfile +++ b/Justfile @@ -9,6 +9,7 @@ migrate-database: reset-database: mix ash.reset + MIX_ENV=test mix ash.setup seed-database: mix run priv/repo/seeds.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0538f45..55e6a0e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -14,6 +14,23 @@ defmodule Mv.Membership.Member do create :create_member do primary? true argument :properties, {:array, :map} + + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + change manage_relationship(:properties, type: :create) end @@ -21,12 +38,176 @@ defmodule Mv.Membership.Member do primary? true require_atomic? false argument :properties, {:array, :map} + + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + change manage_relationship(:properties, on_match: :update, on_no_match: :create) end end attributes do uuid_v7_primary_key :id + + attribute :first_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :last_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :email, :string do + allow_nil? false + constraints min_length: 5, max_length: 254 + end + + attribute :birth_date, :date do + allow_nil? true + end + + attribute :paid, :boolean do + allow_nil? true + end + + attribute :phone_number, :string do + allow_nil? true + end + + attribute :join_date, :date do + allow_nil? true + end + + attribute :exit_date, :date do + allow_nil? true + end + + attribute :notes, :string do + allow_nil? true + end + + attribute :city, :string do + allow_nil? true + end + + attribute :street, :string do + allow_nil? true + end + + attribute :house_number, :string do + allow_nil? true + end + + attribute :postal_code, :string do + allow_nil? true + end + end + + validations do + # Required fields are covered by allow_nil? false + + # First name and last name must not be empty + validate present(:first_name) + validate present(:last_name) + validate present(:email) + + # Birth date not in the future + validate fn changeset, _ -> + birth_date = Ash.Changeset.get_attribute(changeset, :birth_date) + + if birth_date && Date.compare(birth_date, Date.utc_today()) == :gt do + {:error, field: :birth_date, message: "cannot be in the future"} + else + :ok + end + end + + # Join date not in the future + validate fn changeset, _ -> + join_date = Ash.Changeset.get_attribute(changeset, :join_date) + + if join_date && Date.compare(join_date, Date.utc_today()) == :gt do + {:error, field: :join_date, message: "cannot be in the future"} + else + :ok + end + end + + # Exit date not before join date + validate fn changeset, _ -> + join_date = Ash.Changeset.get_attribute(changeset, :join_date) + exit_date = Ash.Changeset.get_attribute(changeset, :exit_date) + + if join_date && exit_date && Date.compare(exit_date, join_date) == :lt do + {:error, field: :exit_date, message: "cannot be before join date"} + else + :ok + end + end + + # Phone number format (only if set) + validate fn changeset, _ -> + phone = Ash.Changeset.get_attribute(changeset, :phone_number) + + if phone && !Regex.match?(~r/^\+?[0-9\- ]{6,20}$/, phone) do + {:error, field: :phone_number, message: "is not a valid phone number"} + else + :ok + end + end + + # Postal code format (only if set) + validate fn changeset, _ -> + postal_code = Ash.Changeset.get_attribute(changeset, :postal_code) + + if postal_code && !Regex.match?(~r/^\d{5}$/, postal_code) do + {:error, field: :postal_code, message: "must consist of 5 digits"} + else + :ok + end + end + + # paid must be boolean if set + validate fn changeset, _ -> + paid = Ash.Changeset.get_attribute(changeset, :paid) + + if not is_nil(paid) and not is_boolean(paid) do + {:error, field: :paid, message: "must be true or false"} + else + :ok + end + end + + # Email validation with EctoCommons.EmailValidator + validate fn changeset, _ -> + email = Ash.Changeset.get_attribute(changeset, :email) + + changeset2 = + {%{}, %{email: :string}} + |> Ecto.Changeset.cast(%{email: email}, [:email]) + |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) + + if changeset2.valid? do + :ok + else + {:error, field: :email, message: "is not a valid email"} + end + end end relationships do diff --git a/lib/membership/property_type.ex b/lib/membership/property_type.ex index 8e42fa6..7444c13 100644 --- a/lib/membership/property_type.ex +++ b/lib/membership/property_type.ex @@ -21,7 +21,7 @@ defmodule Mv.Membership.PropertyType do attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, - description: "Definies the datatype `Property.value` is interpreted as" + description: "Defines the datatype `Property.value` is interpreted as" attribute :description, :string, allow_nil?: true, public?: true diff --git a/mix.exs b/mix.exs index fd60217..a1e30ab 100644 --- a/mix.exs +++ b/mix.exs @@ -69,7 +69,8 @@ defmodule Mv.MixProject do {:bandit, "~> 1.5"}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:ecto_commons, "~> 0.3"} ] end diff --git a/mix.lock b/mix.lock index 8e179eb..962f445 100644 --- a/mix.lock +++ b/mix.lock @@ -13,9 +13,11 @@ "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"}, + "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, + "ex_phone_number": {:hex, :ex_phone_number, "0.4.5", "2065cc48c3e9d1ed9821f50877c32f2f6898362cb990f44147ca217c5d1374ed", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "67163f8706f8cbfef1b1f4b9230c461f19786d0d79fd0b22cbeeefc6f0b99d4a"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, @@ -30,6 +32,7 @@ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "live_debugger": {:hex, :live_debugger, "0.2.4", "2e0b02874ca562ba2d8cebb9e024c25c0ae9c1f4ee499135a70814e1dea6183e", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.4 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "bfd0db143be54ccf2872f15bfd2209fbec1083d0b06b81b4cedeecb2fa9ac208"}, + "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, @@ -58,6 +61,7 @@ "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, diff --git a/priv/repo/migrations/20250617090641_member_fields.exs b/priv/repo/migrations/20250617090641_member_fields.exs new file mode 100644 index 0000000..36a80eb --- /dev/null +++ b/priv/repo/migrations/20250617090641_member_fields.exs @@ -0,0 +1,45 @@ +defmodule Mv.Repo.Migrations.MemberFields 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 + add :first_name, :text, null: false + add :last_name, :text, null: false + add :email, :text, null: false + add :birth_date, :date + add :paid, :boolean + add :phone_number, :text + add :join_date, :date + add :exit_date, :date + add :notes, :text + add :city, :text + add :street, :text + add :house_number, :text + add :postal_code, :text + end + end + + def down do + alter table(:members) do + remove :postal_code + remove :house_number + remove :street + remove :city + remove :notes + remove :exit_date + remove :join_date + remove :phone_number + remove :paid + remove :birth_date + remove :email + remove :last_name + remove :first_name + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 39327d0..2bdcb18 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -7,37 +7,37 @@ alias Mv.Membership for attrs <- [ %{ - name: "Vorname", + name: "First Name", value_type: :string, - description: "Vorname des Mitglieds", + description: "Member's first name", immutable: true, required: true }, %{ - name: "Nachname", + name: "Last Name", value_type: :string, - description: "Nachname des Mitglieds", + description: "Member's last name", immutable: true, required: true }, %{ - name: "Geburtsdatum", + name: "Birth Date", value_type: :date, - description: "Geburtsdatum des Mitglieds", + description: "Member's birth date", immutable: true, required: true }, %{ - name: "Bezahlt", + name: "Paid", value_type: :boolean, - description: "Status des Mitgliedsbeitrages des Mitglieds", + description: "Member's payment status", immutable: true, required: true }, %{ name: "Email", value_type: :email, - description: "Email-Adresse des Mitglieds", + description: "Member's email address", immutable: true, required: true } diff --git a/priv/resource_snapshots/repo/members/20250617090641.json b/priv/resource_snapshots/repo/members/20250617090641.json new file mode 100644 index 0000000..9e2d991 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250617090641.json @@ -0,0 +1,187 @@ +{ + "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": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "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" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "CF80317E7EE409618E08458B10EE122FF605640DDA8CD6000B433F1979614F5D", + "identities": [], + "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 new file mode 100644 index 0000000..7015d34 --- /dev/null +++ b/test/membership/member_test.exs @@ -0,0 +1,110 @@ +defmodule Mv.Membership.MemberTest do + use Mv.DataCase, async: false + alias Mv.Membership + + describe "Fields and Validations" do + @valid_attrs %{ + first_name: "John", + last_name: "Doe", + birth_date: ~D[1990-01-01], + paid: true, + email: "john@example.com", + phone_number: "+49123456789", + join_date: ~D[2020-01-01], + exit_date: nil, + notes: "Test note", + city: "Berlin", + street: "Main Street", + house_number: "1A", + postal_code: "12345" + } + + test "First name is required and must not be empty" do + attrs = Map.put(@valid_attrs, :first_name, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :first_name) =~ "must be present" + end + + test "Last name is required and must not be empty" do + attrs = Map.put(@valid_attrs, :last_name, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :last_name) =~ "must be present" + end + + test "Email is required" do + attrs = Map.put(@valid_attrs, :email, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :email) =~ "must be present" + end + + test "Email must be valid" do + attrs = Map.put(@valid_attrs, :email, "test@") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :email) =~ "is not a valid email" + end + + test "Birth date is optional but must not be in the future" do + attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1)) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :birth_date) =~ "cannot be in the future" + 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) + assert error_message(errors, :phone_number) =~ "is not a valid phone number" + attrs2 = Map.delete(@valid_attrs, :phone_number) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Join date is optional but must not be in the future" do + attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1)) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :join_date) =~ "cannot be in the future" + attrs2 = Map.delete(@valid_attrs, :join_date) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Exit date is optional but must not be before join date if both are specified" do + attrs = Map.put(@valid_attrs, :exit_date, ~D[2010-01-01]) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :exit_date) =~ "cannot be before join date" + attrs2 = Map.delete(@valid_attrs, :exit_date) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Notes is optional" do + attrs = Map.delete(@valid_attrs, :notes) + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "City, street, house number are optional" do + attrs = @valid_attrs |> Map.drop([:city, :street, :house_number]) + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "Postal code is optional but must have 5 digits if specified" do + attrs = Map.put(@valid_attrs, :postal_code, "1234") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :postal_code) =~ "must consist of 5 digits" + attrs2 = Map.delete(@valid_attrs, :postal_code) + assert {:ok, _member} = Membership.create_member(attrs2) + end + end + + # Helper function for error evaluation + defp error_message(errors, field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() + end +end -- 2.47.2 From 6d426a21e826c9b5d6700d8b3637c9c80b746c7b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 17 Jun 2025 13:34:24 +0200 Subject: [PATCH 2/6] liveview for new member fields --- lib/mv_web/components/core_components.ex | 24 +++++++++++++++++ lib/mv_web/member_live/form_component.ex | 15 +++++++++++ lib/mv_web/member_live/index.ex | 7 ++++- lib/mv_web/member_live/show.ex | 34 ++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 1e9b835..c35f1ce 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -673,4 +673,28 @@ defmodule MvWeb.CoreComponents do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + + @doc """ + Renders a list of items with name and value pairs. + + ## Examples + <.generic_list items={[ + {item.name, item.value}, + {other.name, other.value} + ]} /> + """ + attr :items, :list, required: true, doc: "List of {name, value} tuples" + + def generic_list(assigns) do + ~H""" +
+
+
+
{name}
+
{value}
+
+
+
+ """ + end end diff --git a/lib/mv_web/member_live/form_component.ex b/lib/mv_web/member_live/form_component.ex index 101cf6c..0f06cfa 100644 --- a/lib/mv_web/member_live/form_component.ex +++ b/lib/mv_web/member_live/form_component.ex @@ -36,6 +36,21 @@ defmodule MvWeb.MemberLive.FormComponent do phx-change="validate" phx-submit="save" > + <.input field={@form[:first_name]} label="First Name" required /> + <.input field={@form[:last_name]} label="Last Name" required /> + <.input field={@form[:email]} label="Email" required type="email" /> + <.input field={@form[:birth_date]} label="Birth Date" type="date" /> + <.input field={@form[:paid]} label="Paid" type="checkbox" /> + <.input field={@form[:phone_number]} label="Phone Number" /> + <.input field={@form[:join_date]} label="Join Date" type="date" /> + <.input field={@form[:exit_date]} label="Exit Date" type="date" /> + <.input field={@form[:notes]} label="Notes" /> + <.input field={@form[:city]} label="City" /> + <.input field={@form[:street]} label="Street" /> + <.input field={@form[:house_number]} label="House Number" /> + <.input field={@form[:postal_code]} label="Postal Code" /> + +

Custom Properties

<.inputs_for :let={f_property} field={@form[:properties]}> <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> <.inputs_for :let={value_form} field={f_property[:value]}> diff --git a/lib/mv_web/member_live/index.ex b/lib/mv_web/member_live/index.ex index 4e37429..96c7a41 100644 --- a/lib/mv_web/member_live/index.ex +++ b/lib/mv_web/member_live/index.ex @@ -18,7 +18,12 @@ defmodule MvWeb.MemberLive.Index do rows={@streams.members} row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end} > - <:col :let={{_id, member}} label="Id">{member.id} + + <:col :let={{_id, member}} label="First Name">{member.first_name} + <:col :let={{_id, member}} label="Last Name">{member.last_name} + <:col :let={{_id, member}} label="Email">{member.email} + <:col :let={{_id, member}} label="City">{member.city} + <:col :let={{_id, member}} label="Join Date">{member.join_date} <:action :let={{_id, member}}>
diff --git a/lib/mv_web/member_live/show.ex b/lib/mv_web/member_live/show.ex index 47e0f92..1608a9a 100644 --- a/lib/mv_web/member_live/show.ex +++ b/lib/mv_web/member_live/show.ex @@ -1,11 +1,12 @@ defmodule MvWeb.MemberLive.Show do use MvWeb, :live_view + import Ash.Query @impl true def render(assigns) do ~H""" <.header> - Member {@member.id} + {@member.first_name} {@member.last_name} <:subtitle>This is a member record from your database. <:actions> @@ -17,8 +18,30 @@ defmodule MvWeb.MemberLive.Show do <.list> <:item title="Id">{@member.id} + <:item title="First Name">{@member.first_name} + <:item title="Last Name">{@member.last_name} + <:item title="Email">{@member.email} + <:item title="Birth Date">{@member.birth_date} + <:item title="Paid">{if @member.paid, do: "Yes", else: "No"} + <:item title="Phone Number">{@member.phone_number} + <:item title="Join Date">{@member.join_date} + <:item title="Exit Date">{@member.exit_date} + <:item title="Notes">{@member.notes} + <:item title="City">{@member.city} + <:item title="Street">{@member.street} + <:item title="House Number">{@member.house_number} + <:item title="Postal Code">{@member.postal_code} +

Custom Properties

+ <.generic_list + items={Enum.map(@member.properties, fn p -> + { + p.property_type && p.property_type.name, # name + case p.value do %{value: v} -> v; v -> v end # value + } + end)} + /> <.back navigate={~p"/members"}>Back to members <.modal @@ -46,10 +69,17 @@ defmodule MvWeb.MemberLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do + query = + Mv.Membership.Member + |> filter(id == ^id) + |> load(properties: [:property_type]) + + member = Ash.read_one!(query) + {:noreply, socket |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:member, Ash.get!(Mv.Membership.Member, id))} + |> assign(:member, member)} end defp page_title(:show), do: "Show Member" -- 2.47.2 From dab54bcef9bd3ebfce4fbe82d9937345b285a32e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 17 Jun 2025 13:45:24 +0200 Subject: [PATCH 3/6] replace default fields from properties with example fields --- priv/repo/seeds.exs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 2bdcb18..1497096 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -7,37 +7,30 @@ alias Mv.Membership for attrs <- [ %{ - name: "First Name", + name: "String Field", value_type: :string, - description: "Member's first name", + description: "Example for a field of type string", immutable: true, required: true }, %{ - name: "Last Name", - value_type: :string, - description: "Member's last name", - immutable: true, - required: true - }, - %{ - name: "Birth Date", + name: "Date Field", value_type: :date, - description: "Member's birth date", + description: "Example for a field of type date", immutable: true, required: true }, %{ - name: "Paid", + name: "Boolean Field", value_type: :boolean, - description: "Member's payment status", + description: "Example for a field of type boolean", immutable: true, required: true }, %{ - name: "Email", + name: "Email Field", value_type: :email, - description: "Member's email address", + description: "Example for a field of type email", immutable: true, required: true } -- 2.47.2 From 6f88a635cc6b5238cac7ce64522b682e5d6aca81 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 17 Jun 2025 15:28:52 +0200 Subject: [PATCH 4/6] fix member deletion: property delete on cascade --- lib/membership/member.ex | 9 +- lib/membership/property.ex | 4 + .../20250617132424_member_delete.exs | 38 +++++++ .../repo/properties/20250617132424.json | 105 ++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20250617132424_member_delete.exs create mode 100644 priv/resource_snapshots/repo/properties/20250617132424.json diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 55e6a0e..055cc48 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -9,7 +9,7 @@ defmodule Mv.Membership.Member do end actions do - defaults [:read, :destroy] + defaults [:read] create :create_member do primary? true @@ -34,6 +34,11 @@ defmodule Mv.Membership.Member do change manage_relationship(:properties, type: :create) end + destroy :destroy do + primary? true + change Ash.Resource.Change.Builtins.cascade_destroy(:properties) + end + update :update_member do primary? true require_atomic? false @@ -211,6 +216,6 @@ defmodule Mv.Membership.Member do end relationships do - has_many :properties, Mv.Membership.Property + has_many :properties, Mv.Membership.Property, destination_attribute: :member_id end end diff --git a/lib/membership/property.ex b/lib/membership/property.ex index 0bd5eab..2c432a8 100644 --- a/lib/membership/property.ex +++ b/lib/membership/property.ex @@ -6,6 +6,10 @@ defmodule Mv.Membership.Property do postgres do table "properties" repo Mv.Repo + + references do + reference :member, on_delete: :delete + end end actions do diff --git a/priv/repo/migrations/20250617132424_member_delete.exs b/priv/repo/migrations/20250617132424_member_delete.exs new file mode 100644 index 0000000..f0f539a --- /dev/null +++ b/priv/repo/migrations/20250617132424_member_delete.exs @@ -0,0 +1,38 @@ +defmodule Mv.Repo.Migrations.MemberDelete 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 + drop constraint(:properties, "properties_member_id_fkey") + + alter table(:properties) do + modify :member_id, + references(:members, + column: :id, + name: "properties_member_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :delete_all + ) + end + end + + def down do + drop constraint(:properties, "properties_member_id_fkey") + + alter table(:properties) do + modify :member_id, + references(:members, + column: :id, + name: "properties_member_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end +end diff --git a/priv/resource_snapshots/repo/properties/20250617132424.json b/priv/resource_snapshots/repo/properties/20250617132424.json new file mode 100644 index 0000000..49e3b48 --- /dev/null +++ b/priv/resource_snapshots/repo/properties/20250617132424.json @@ -0,0 +1,105 @@ +{ + "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?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "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": "properties_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "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": "properties_property_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "property_types" + }, + "scale": null, + "size": null, + "source": "property_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4F17BE0106435A1D75D46A3ABDE6A3DA20FC9B1C43D101B6C310009279DD7CBA", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "properties" +} \ No newline at end of file -- 2.47.2 From 2ab3332941a980a860783f5ebec457175828653d Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 17 Jun 2025 16:07:52 +0200 Subject: [PATCH 5/6] chore: fix linting --- lib/membership/member.ex | 118 ++++++++++++++++----------------- lib/mv_web/member_live/show.ex | 17 +++-- 2 files changed, 70 insertions(+), 65 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 055cc48..54cc546 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -64,65 +64,6 @@ defmodule Mv.Membership.Member do end end - attributes do - uuid_v7_primary_key :id - - attribute :first_name, :string do - allow_nil? false - constraints min_length: 1 - end - - attribute :last_name, :string do - allow_nil? false - constraints min_length: 1 - end - - attribute :email, :string do - allow_nil? false - constraints min_length: 5, max_length: 254 - end - - attribute :birth_date, :date do - allow_nil? true - end - - attribute :paid, :boolean do - allow_nil? true - end - - attribute :phone_number, :string do - allow_nil? true - end - - attribute :join_date, :date do - allow_nil? true - end - - attribute :exit_date, :date do - allow_nil? true - end - - attribute :notes, :string do - allow_nil? true - end - - attribute :city, :string do - allow_nil? true - end - - attribute :street, :string do - allow_nil? true - end - - attribute :house_number, :string do - allow_nil? true - end - - attribute :postal_code, :string do - allow_nil? true - end - end - validations do # Required fields are covered by allow_nil? false @@ -215,6 +156,65 @@ defmodule Mv.Membership.Member do end end + attributes do + uuid_v7_primary_key :id + + attribute :first_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :last_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :email, :string do + allow_nil? false + constraints min_length: 5, max_length: 254 + end + + attribute :birth_date, :date do + allow_nil? true + end + + attribute :paid, :boolean do + allow_nil? true + end + + attribute :phone_number, :string do + allow_nil? true + end + + attribute :join_date, :date do + allow_nil? true + end + + attribute :exit_date, :date do + allow_nil? true + end + + attribute :notes, :string do + allow_nil? true + end + + attribute :city, :string do + allow_nil? true + end + + attribute :street, :string do + allow_nil? true + end + + attribute :house_number, :string do + allow_nil? true + end + + attribute :postal_code, :string do + allow_nil? true + end + end + relationships do has_many :properties, Mv.Membership.Property, destination_attribute: :member_id end diff --git a/lib/mv_web/member_live/show.ex b/lib/mv_web/member_live/show.ex index 1608a9a..c58b0e3 100644 --- a/lib/mv_web/member_live/show.ex +++ b/lib/mv_web/member_live/show.ex @@ -34,14 +34,19 @@ defmodule MvWeb.MemberLive.Show do

Custom Properties

- <.generic_list - items={Enum.map(@member.properties, fn p -> + <.generic_list items={ + Enum.map(@member.properties, fn p -> { - p.property_type && p.property_type.name, # name - case p.value do %{value: v} -> v; v -> v end # value + # name + p.property_type && p.property_type.name, + # value + case p.value do + %{value: v} -> v + v -> v + end } - end)} - /> + end) + } /> <.back navigate={~p"/members"}>Back to members <.modal -- 2.47.2 From 7f034740b07b603e64faaac6063581675827e233 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Jun 2025 08:21:10 +0200 Subject: [PATCH 6/6] review: removed leftovers and ash use builtin validation functions --- Justfile | 2 +- lib/membership/member.ex | 77 +++++++++------------------------------- 2 files changed, 18 insertions(+), 61 deletions(-) diff --git a/Justfile b/Justfile index 4480991..d82bae4 100644 --- a/Justfile +++ b/Justfile @@ -9,7 +9,7 @@ migrate-database: reset-database: mix ash.reset - MIX_ENV=test mix ash.setup + MIX_ENV=test mix ash.reset seed-database: mix run priv/repo/seeds.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 54cc546..ec2b16f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -9,7 +9,7 @@ defmodule Mv.Membership.Member do end actions do - defaults [:read] + defaults [:read, :destroy] create :create_member do primary? true @@ -34,11 +34,6 @@ defmodule Mv.Membership.Member do change manage_relationship(:properties, type: :create) end - destroy :destroy do - primary? true - change Ash.Resource.Change.Builtins.cascade_destroy(:properties) - end - update :update_member do primary? true require_atomic? false @@ -73,71 +68,33 @@ defmodule Mv.Membership.Member do validate present(:email) # Birth date not in the future - validate fn changeset, _ -> - birth_date = Ash.Changeset.get_attribute(changeset, :birth_date) - - if birth_date && Date.compare(birth_date, Date.utc_today()) == :gt do - {:error, field: :birth_date, message: "cannot be in the future"} - else - :ok - end - end + validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:birth_date)], + message: "cannot be in the future" # Join date not in the future - validate fn changeset, _ -> - join_date = Ash.Changeset.get_attribute(changeset, :join_date) + validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:join_date)], + message: "cannot be in the future" - if join_date && Date.compare(join_date, Date.utc_today()) == :gt do - {:error, field: :join_date, message: "cannot be in the future"} - else - :ok - end - end # Exit date not before join date - validate fn changeset, _ -> - join_date = Ash.Changeset.get_attribute(changeset, :join_date) - exit_date = Ash.Changeset.get_attribute(changeset, :exit_date) + validate compare(:exit_date, greater_than: :join_date), + where: [present([:join_date, :exit_date])], + message: "cannot be before join date" - if join_date && exit_date && Date.compare(exit_date, join_date) == :lt do - {:error, field: :exit_date, message: "cannot be before join date"} - else - :ok - end - end # Phone number format (only if set) - validate fn changeset, _ -> - phone = Ash.Changeset.get_attribute(changeset, :phone_number) + validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/), + where: [present(:phone_number)], + message: "is not a valid phone number" - if phone && !Regex.match?(~r/^\+?[0-9\- ]{6,20}$/, phone) do - {:error, field: :phone_number, message: "is not a valid phone number"} - else - :ok - end - end # Postal code format (only if set) - validate fn changeset, _ -> - postal_code = Ash.Changeset.get_attribute(changeset, :postal_code) + validate match(:postal_code, ~r/^\d{5}$/), + where: [present(:postal_code)], + message: "must consist of 5 digits" - if postal_code && !Regex.match?(~r/^\d{5}$/, postal_code) do - {:error, field: :postal_code, message: "must consist of 5 digits"} - else - :ok - end - end - - # paid must be boolean if set - validate fn changeset, _ -> - paid = Ash.Changeset.get_attribute(changeset, :paid) - - if not is_nil(paid) and not is_boolean(paid) do - {:error, field: :paid, message: "must be true or false"} - else - :ok - end - end # Email validation with EctoCommons.EmailValidator validate fn changeset, _ -> @@ -216,6 +173,6 @@ defmodule Mv.Membership.Member do end relationships do - has_many :properties, Mv.Membership.Property, destination_attribute: :member_id + has_many :properties, Mv.Membership.Property end end -- 2.47.2