defmodule Mv.Membership do @moduledoc """ Ash Domain for membership management. ## Resources - `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) - `Group` - Groups that members can belong to - `MemberGroup` - Join table for many-to-many relationship between Members and Groups ## 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`, `list_required_custom_fields/1`, etc. - Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3` - Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1` - Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1` ## Admin Interface The domain is configured with AshAdmin for management UI. """ use Ash.Domain, extensions: [AshAdmin.Domain, AshPhoenix] require Ash.Query import Ash.Expr admin do show? true end resources do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read define :update_member, action: :update_member define :destroy_member, action: :destroy end resource Mv.Membership.CustomFieldValue do define :create_custom_field_value, action: :create define :list_custom_field_values, action: :read define :update_custom_field_value, action: :update define :destroy_custom_field_value, action: :destroy end resource Mv.Membership.CustomField do define :create_custom_field, action: :create define :list_custom_fields, action: :read define :update_custom_field, action: :update 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 define :update_member_field_visibility, action: :update_member_field_visibility define :update_single_member_field_visibility, action: :update_single_member_field_visibility end resource Mv.Membership.Group do define :create_group, action: :create define :list_groups, action: :read define :update_group, action: :update define :destroy_group, action: :destroy end resource Mv.Membership.MemberGroup do define :create_member_group, action: :create define :list_member_groups, action: :read define :destroy_member_group, action: :destroy 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 "Club Name" 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") || "Club Name" Mv.Membership.Setting |> Ash.Changeset.for_create(:create, %{ club_name: default_club_name, member_field_visibility: %{"exit_date" => false} }) |> 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 @doc """ Lists only required custom fields. This is an optimized version that filters at the database level instead of loading all custom fields and filtering in memory. Requires an actor for authorization (CustomField read policy). Callers must pass `actor:`; no default. ## Options - `:actor` - Required. The actor for authorization (e.g. current user). All roles can read CustomField; actor must have a valid role. ## Returns - `{:ok, required_custom_fields}` - List of required custom fields - `{:error, :missing_actor}` - When actor is nil (caller must pass actor) - `{:error, error}` - Error reading custom fields (e.g. Forbidden) ## Examples iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor) iex> Enum.all?(required_fields, & &1.required) true iex> Mv.Membership.list_required_custom_fields(actor: nil) {:error, :missing_actor} """ def list_required_custom_fields(actor: actor) when not is_nil(actor) do Mv.Membership.CustomField |> Ash.Query.filter(expr(required == true)) |> Ash.read(domain: __MODULE__, actor: actor) end def list_required_custom_fields(actor: nil), do: {:error, :missing_actor} @doc """ Updates the member field visibility configuration. This is a specialized action for updating only the member field visibility settings. It validates that all keys are valid member fields and all values are booleans. ## Parameters - `settings` - The settings record to update - `visibility_config` - A map of member field names (strings) to boolean visibility values (e.g., `%{"street" => false, "house_number" => false}`) ## 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_member_field_visibility(settings, %{"street" => false, "house_number" => false}) iex> updated.member_field_visibility %{"street" => false, "house_number" => false} """ def update_member_field_visibility(settings, visibility_config) do settings |> Ash.Changeset.for_update(:update_member_field_visibility, %{ member_field_visibility: visibility_config }) |> Ash.update(domain: __MODULE__) end @doc """ Atomically updates a single field in the member field visibility configuration. This action uses PostgreSQL's jsonb_set function to atomically update a single key in the JSONB map, preventing lost updates in concurrent scenarios. This is the preferred method for updating individual field visibility settings. ## Parameters - `settings` - The settings record to update - `field` - The member field name as a string (e.g., "street", "house_number") - `show_in_overview` - Boolean value indicating visibility ## 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_single_member_field_visibility(settings, field: "street", show_in_overview: false) iex> updated.member_field_visibility["street"] false """ def update_single_member_field_visibility(settings, field: field, show_in_overview: show_in_overview ) do settings |> Ash.Changeset.new() |> Ash.Changeset.set_argument(:field, field) |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) |> Ash.update(domain: __MODULE__) end @doc """ Gets a group by its slug. Uses `Ash.Query.filter` to efficiently find a group by its slug. The unique index on `slug` ensures efficient lookup performance. The slug lookup is case-sensitive (exact match required). ## Parameters - `slug` - The slug to search for (case-sensitive) - `opts` - Options including `:actor` for authorization ## Returns - `{:ok, group}` - Found group (with members and member_count loaded) - `{:ok, nil}` - Group not found - `{:error, error}` - Error reading group ## Examples iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor) iex> group.name "Board Members" iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor) {:ok, nil} """ def get_group_by_slug(slug, opts \\ []) do load = Keyword.get(opts, :load, []) require Ash.Query query = Mv.Membership.Group |> Ash.Query.filter(slug == ^slug) |> Ash.Query.load(load) opts |> Keyword.delete(:load) |> Keyword.put_new(:domain, __MODULE__) |> then(&Ash.read_one(query, &1)) end end