Compare commits

..

7 commits

Author SHA1 Message Date
Renovate Bot
4287ee023e chore(deps): update mix dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 00:17:12 +00:00
ff88ab3739 Merge pull request 'property values as maps closes #53' (#56) from property_values into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #56
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-05-29 15:34:23 +02:00
859f5f4497
feat: add custom email type for validation
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 15:33:30 +02:00
3e2140fda7
chore(Justfile): allow regenerating migrations by commit hash 2025-05-29 15:33:29 +02:00
723d9c7205
choose input filed type by value_type 2025-05-29 15:33:29 +02:00
b849cfa3df
property value as Union type 2025-05-29 15:33:26 +02:00
e3779a73ff
chore: add regen_migrations script and seed-database to Justfile 2025-05-29 15:32:56 +02:00
11 changed files with 153 additions and 33 deletions

View file

@ -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
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}}"

34
lib/membership/email.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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} />
</.inputs_for>
<input
type="hidden"
name={f_property[:property_type_id].name}
@ -95,12 +107,27 @@ defmodule MvWeb.MemberLive.FormComponent do
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
end
params = %{
"properties" =>
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]
)

View file

@ -4,7 +4,7 @@
"ash_phoenix": {:hex, :ash_phoenix, "2.3.2", "1f12a1099dba885c10d71d77ce343c751e570e75af8d22a23df6e350a46bc98f", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "915442f2b2d2d5dfab853c1afe4bd93e6ac9112abe09c5eff37d15457a1d8ac9"},
"ash_postgres": {:hex, :ash_postgres, "2.5.22", "ef690998550f14006cf18187a936f94aefe14369c98ab21bfc56145c45fcf2c2", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "e9703332e3db5cfe9522278469d08e9f65faf2e33be4c0b92c361dbe38ef9887"},
"ash_sql": {:hex, :ash_sql, "0.2.76", "4afac3284194f3d7820b7edc1263ac1eb232a25734406ad7b2284683b9384205", [:mix], [{:ash, "~> 3.5", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f6bc02d8c4cba3f8f9a532e6ff73eaf8a4f7685257646a58fe7a320cfaf10c39"},
"bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"},
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},

View file

@ -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

View file

@ -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

View file

@ -16,7 +16,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344",
"hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -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,

View file

@ -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,