From 193618eacef9baa1101fb39e1d1a12b0681b672d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:56:50 +0100 Subject: [PATCH] chore: adds settings ressource and migration --- lib/membership/membership.ex | 77 ++++++++++++++++++ lib/membership/setting.ex | 80 +++++++++++++++++++ .../20251127134451_add_settings_table.exs | 31 +++++++ .../repo/settings/20251127134451.json | 67 ++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 lib/membership/setting.ex create mode 100644 priv/repo/migrations/20251127134451_add_settings_table.exs create mode 100644 priv/resource_snapshots/repo/settings/20251127134451.json diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7891d2e..c9d0466 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -6,12 +6,14 @@ defmodule Mv.Membership do - `Member` - Club members with personal information and custom field values - `CustomFieldValue` - Dynamic custom field values attached to members - `CustomField` - Schema definitions for custom fields + - `Setting` - Global application settings (singleton) ## Public API The domain exposes these main actions: - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc. + - Settings management: `get_settings/0`, `update_settings/2` ## Admin Interface The domain is configured with AshAdmin for management UI. @@ -45,5 +47,80 @@ defmodule Mv.Membership do define :destroy_custom_field, action: :destroy_with_values define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id] end + + resource Mv.Membership.Setting do + # Note: create action exists but is not exposed via code interface + # It's only used internally as fallback in get_settings/0 + # Settings should be created via seed script + define :update_settings, action: :update + end + end + + # Singleton pattern: Get the single settings record + @doc """ + Gets the global settings. + + Settings should normally be created via the seed script (`priv/repo/seeds.exs`). + If no settings exist, this function will create them as a fallback using the + `ASSOCIATION_NAME` environment variable or "Mitgliederverwaltung" as default. + + ## Returns + + - `{:ok, settings}` - The settings record + - `{:ok, nil}` - No settings exist (should not happen if seeds were run) + - `{:error, error}` - Error reading settings + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> settings.club_name + "My Club" + + """ + def get_settings do + # Try to get the first (and only) settings record + case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do + {:ok, nil} -> + # No settings exist - create as fallback (should normally be created via seed script) + default_club_name = System.get_env("ASSOCIATION_NAME") || "Mitgliederverwaltung" + + Mv.Membership.Setting + |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) + |> Ash.create!(domain: __MODULE__) + |> then(fn settings -> {:ok, settings} end) + + {:ok, settings} -> + {:ok, settings} + + {:error, error} -> + {:error, error} + end + end + + @doc """ + Updates the global settings. + + ## Parameters + + - `settings` - The settings record to update + - `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`) + + ## Returns + + - `{:ok, updated_settings}` - Successfully updated settings + - `{:error, error}` - Validation or update error + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Club"}) + iex> updated.club_name + "New Club" + + """ + def update_settings(settings, attrs) do + settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex new file mode 100644 index 0000000..47b9dd8 --- /dev/null +++ b/lib/membership/setting.ex @@ -0,0 +1,80 @@ +defmodule Mv.Membership.Setting do + @moduledoc """ + Ash resource representing global application settings. + + ## Overview + Settings is a singleton resource that stores global configuration for the association, + such as the club name and branding information. There should only ever be one settings + record in the database. + + ## Attributes + - `club_name` - The name of the association/club (required, cannot be empty) + + ## Singleton Pattern + This resource uses a singleton pattern - there should only be one settings record. + The resource is designed to be read and updated, but not created or destroyed + through normal CRUD operations. Initial settings should be seeded. + + ## Environment Variable Support + The `club_name` can be set via the `ASSOCIATION_NAME` environment variable. + If set, the environment variable value is used as a fallback when no database + value exists. Database values always take precedence over environment variables. + + ## Examples + + # Get current settings + {:ok, settings} = Mv.Membership.get_settings() + settings.club_name # => "My Club" + + # Update club name + {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) + """ + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + + postgres do + table "settings" + repo Mv.Repo + end + + resource do + description "Global application settings (singleton resource)" + end + + actions do + defaults [:read] + + # Internal create action - not exposed via code interface + # Used only as fallback in get_settings/0 if settings don't exist + # Settings should normally be created via seed script + create :create do + accept [:club_name] + end + + update :update do + primary? true + accept [:club_name] + end + end + + attributes do + uuid_primary_key :id + + attribute :club_name, :string, + allow_nil?: false, + public?: true, + description: "The name of the association/club", + constraints: [ + trim?: true, + min_length: 1 + ] + + timestamps() + end + + validations do + validate present(:club_name), on: [:create, :update] + validate string_length(:club_name, min: 1), on: [:create, :update] + end +end diff --git a/priv/repo/migrations/20251127134451_add_settings_table.exs b/priv/repo/migrations/20251127134451_add_settings_table.exs new file mode 100644 index 0000000..e08ba1d --- /dev/null +++ b/priv/repo/migrations/20251127134451_add_settings_table.exs @@ -0,0 +1,31 @@ +defmodule Mv.Repo.Migrations.AddSettingsTable 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 + create table(:settings, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :club_name, :text, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + # Note: Singleton pattern is enforced at application level via get_settings/0 + # which creates the record if it doesn't exist and only allows updates + end + + def down do + drop table(:settings) + end +end diff --git a/priv/resource_snapshots/repo/settings/20251127134451.json b/priv/resource_snapshots/repo/settings/20251127134451.json new file mode 100644 index 0000000..fefc223 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251127134451.json @@ -0,0 +1,67 @@ +{ + "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": "club_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "353EB39F18B97C596A77A78A060FB9DE075AAD731F74F64AB62D357CBCDEC914", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file