diff --git a/Justfile b/Justfile index b3541fd..13e8a01 100644 --- a/Justfile +++ b/Justfile @@ -1,4 +1,4 @@ -run: install-dependencies start-database migrate-database +run: install-dependencies start-database migrate-database seed-database mix phx.server install-dependencies: @@ -10,6 +10,9 @@ migrate-database: reset-database: mix ash.reset +seed-database: + mix run priv/repo/seeds.exs + start-database: docker compose up -d @@ -36,4 +39,35 @@ build-docker-container: # This is meant for debugging the container build process only. run-docker-container: build-docker-container - docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung \ No newline at end of file + docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung + +# Usage: +# just regen-migrations migration_name [commit_hash] +# If commit_hash is given, rollback & delete the migrations from that commit. +# Otherwise, rollback & delete all untracked migrations. +regen-migrations migration_name commit_hash='': + #!/usr/bin/env bash + set -euo pipefail + # Pick migrations either from the given commit or untracked files + if [ -n "{{commit_hash}}" ]; then + echo "→ Rolling back migrations from commit {{commit_hash}}" + MIG_FILES=$(git show --name-only --pretty=format: "{{commit_hash}}" \ + | grep -E "^priv/repo/migrations/|^priv/resource_snapshots") + else + echo "→ Rolling back all untracked migrations" + MIG_FILES=$(git ls-files --others priv/repo/migrations) + fi + + # Roll back in Ash + COUNT=$(echo "$MIG_FILES" | wc -l) + mix ash_postgres.rollback -n "$COUNT" + + # Remove the migration files + echo removing $MIG_FILES + echo "$MIG_FILES" | xargs rm -f + + # Also clean up any untracked resource snapshots + git ls-files --others priv/resource_snapshots | xargs rm -f + + # Generate a fresh migration + mix ash.codegen --name "{{migration_name}}" diff --git a/lib/membership/email.ex b/lib/membership/email.ex new file mode 100644 index 0000000..eacd548 --- /dev/null +++ b/lib/membership/email.ex @@ -0,0 +1,34 @@ +defmodule Mv.Membership.Email do + @constraints [ + match: ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/, + trim?: true, + min_length: 5, + max_length: 254 + ] + + use Ash.Type.NewType, + subtype_of: :string, + constraints: @constraints + + @impl true + def cast_input(value, _) when is_binary(value) do + value = if @constraints[:trim?], do: String.trim(value), else: value + + cond do + @constraints[:min_length] && String.length(value) < @constraints[:min_length] -> + :error + + @constraints[:max_length] && String.length(value) > @constraints[:max_length] -> + :error + + @constraints[:match] && !Regex.match?(@constraints[:match], value) -> + :error + + true -> + {:ok, value} + end + end + + @impl true + def cast_input(_, _), do: :error +end diff --git a/lib/membership/property.ex b/lib/membership/property.ex index 433fc63..0bd5eab 100644 --- a/lib/membership/property.ex +++ b/lib/membership/property.ex @@ -16,8 +16,17 @@ defmodule Mv.Membership.Property do attributes do uuid_primary_key :id - attribute :value, :string, - description: "Speichert den Wert, Typ-Interpretation per property_type.typ" + attribute :value, :union, + constraints: [ + storage: :type_and_value, + types: [ + boolean: [type: :boolean], + date: [type: :date], + integer: [type: :integer], + string: [type: :string], + email: [type: Mv.Membership.Email] + ] + ] end relationships do @@ -25,4 +34,8 @@ defmodule Mv.Membership.Property do belongs_to :property_type, Mv.Membership.PropertyType end + + calculations do + calculate :value_to_string, :string, expr(value[:value] <> "") + end end diff --git a/lib/membership/property_type.ex b/lib/membership/property_type.ex index 560f679..8e42fa6 100644 --- a/lib/membership/property_type.ex +++ b/lib/membership/property_type.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.PropertyType do actions do defaults [:create, :read, :update, :destroy] - default_accept [:name, :type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required] end attributes do @@ -18,7 +18,8 @@ defmodule Mv.Membership.PropertyType do attribute :name, :string, allow_nil?: false, public?: true - attribute :type, :string, + attribute :value_type, :atom, + constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, description: "Definies the datatype `Property.value` is interpreted as" diff --git a/lib/mv_web/member_live/form_component.ex b/lib/mv_web/member_live/form_component.ex index b91d3c0..101cf6c 100644 --- a/lib/mv_web/member_live/form_component.ex +++ b/lib/mv_web/member_live/form_component.ex @@ -9,7 +9,11 @@ defmodule MvWeb.MemberLive.FormComponent do Enum.map(property_types, fn pt -> %{ "property_type_id" => pt.id, - "value" => nil + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } } end) @@ -34,7 +38,15 @@ defmodule MvWeb.MemberLive.FormComponent do > <.inputs_for :let={f_property} field={@form[:properties]}> <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> - <.input field={f_property[:value]} label={type && type.name} /> + <.inputs_for :let={value_form} field={f_property[:value]}> + <% input_type = + cond do + type && type.value_type == :boolean -> "checkbox" + type && type.value_type == :date -> :date + true -> :text + end %> + <.input field={value_form[:value]} label={type && type.name} type={input_type} /> + + Enum.map(member.properties, fn prop -> + %{ + "property_type_id" => prop.property_type_id, + "value" => %{ + "_union_type" => Atom.to_string(prop.value.type), + "type" => prop.value.type, + "value" => prop.value.value + } + } + end) + } + form = AshPhoenix.Form.for_update( member, :update_member, api: Mv.Membership, as: "member", + params: params, forms: [auto?: true] ) diff --git a/priv/repo/migrations/20250514151922_initial_migration.exs b/priv/repo/migrations/20250528163901_initial_migration.exs similarity index 96% rename from priv/repo/migrations/20250514151922_initial_migration.exs rename to priv/repo/migrations/20250528163901_initial_migration.exs index 64915c2..bf36e43 100644 --- a/priv/repo/migrations/20250514151922_initial_migration.exs +++ b/priv/repo/migrations/20250528163901_initial_migration.exs @@ -11,7 +11,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do create table(:property_types, primary_key: false) do add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true add :name, :text, null: false - add :type, :text, null: false + add :value_type, :text, null: false add :description, :text add :immutable, :boolean, null: false, default: false add :required, :boolean, null: false, default: false @@ -21,7 +21,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do create table(:properties, primary_key: false) do add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true - add :value, :text + add :value, :map add :member_id, :uuid add :property_type_id, :uuid end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8fca144..39327d0 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -2,38 +2,49 @@ # # mix run priv/repo/seeds.exs # -# Inside the script, you can read and write to any of your -# repositories directly: -# -# Mv.Repo.insert!(%Mv.SomeSchema{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. alias Mv.Membership for attrs <- [ %{ name: "Vorname", - type: "string", + value_type: :string, description: "Vorname des Mitglieds", immutable: true, required: true }, + %{ + name: "Nachname", + value_type: :string, + description: "Nachname des Mitglieds", + immutable: true, + required: true + }, + %{ + name: "Geburtsdatum", + value_type: :date, + description: "Geburtsdatum des Mitglieds", + immutable: true, + required: true + }, + %{ + name: "Bezahlt", + value_type: :boolean, + description: "Status des Mitgliedsbeitrages des Mitglieds", + immutable: true, + required: true + }, %{ name: "Email", - type: "string", + value_type: :email, description: "Email-Adresse des Mitglieds", immutable: true, required: true } ] do - # upsert?: true sorgt dafür, dass bei bestehendem Namen kein Fehler, - # sondern ein Update (hier effektiv No-Op) ausgeführt wird - {:ok, _} = - Membership.create_property_type( - attrs, - upsert?: true, - upsert_identity: :unique_name - ) + Membership.create_property_type!( + attrs, + upsert?: true, + upsert_identity: :unique_name + ) end diff --git a/priv/resource_snapshots/repo/members/20250514151922.json b/priv/resource_snapshots/repo/members/20250528163901.json similarity index 87% rename from priv/resource_snapshots/repo/members/20250514151922.json rename to priv/resource_snapshots/repo/members/20250528163901.json index df811d8..2d08b17 100644 --- a/priv/resource_snapshots/repo/members/20250514151922.json +++ b/priv/resource_snapshots/repo/members/20250528163901.json @@ -16,7 +16,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344", + "hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A", "identities": [], "multitenancy": { "attribute": null, diff --git a/priv/resource_snapshots/repo/properties/20250514151922.json b/priv/resource_snapshots/repo/properties/20250528163901.json similarity index 95% rename from priv/resource_snapshots/repo/properties/20250514151922.json rename to priv/resource_snapshots/repo/properties/20250528163901.json index 0707bcf..daf3222 100644 --- a/priv/resource_snapshots/repo/properties/20250514151922.json +++ b/priv/resource_snapshots/repo/properties/20250528163901.json @@ -18,7 +18,7 @@ "references": null, "size": null, "source": "value", - "type": "text" + "type": "map" }, { "allow_nil?": true, @@ -84,7 +84,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "F2A42A3427E0428637F465E4F357A3BE21B33231F94CF77B4843084128F6BDA5", + "hash": "8CF241CB9E8239511914EDEC96186BB7879529372BD8A4162431CCE9961F4F1B", "identities": [], "multitenancy": { "attribute": null, diff --git a/priv/resource_snapshots/repo/property_types/20250514151922.json b/priv/resource_snapshots/repo/property_types/20250528163901.json similarity index 94% rename from priv/resource_snapshots/repo/property_types/20250514151922.json rename to priv/resource_snapshots/repo/property_types/20250528163901.json index 5da5fa8..55f066a 100644 --- a/priv/resource_snapshots/repo/property_types/20250514151922.json +++ b/priv/resource_snapshots/repo/property_types/20250528163901.json @@ -27,7 +27,7 @@ "primary_key?": false, "references": null, "size": null, - "source": "type", + "source": "value_type", "type": "text" }, { @@ -66,7 +66,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "47210108DE1E7B2A20A67205E875B3440526941E61AB95B166976E8CD8AA0955", + "hash": "F98A723AE0D20005FBE4205E46ABEE09A88DFF9334C85BADC1FBEEF100F3E25B", "identities": [ { "all_tenants?": false,