defmodule Mv.Membership.Group do @moduledoc """ Ash resource representing a group that members can belong to. ## Overview Groups allow organizing members into categories (e.g., "Board Members", "Active Members"). Each member can belong to multiple groups, and each group can contain multiple members. ## Attributes - `name` - Unique group name (required, max 100 chars, case-insensitive uniqueness) - `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name, immutable) - `description` - Optional description (max 500 chars) ## Relationships - `has_many :member_groups` - Relationship to MemberGroup join table - `many_to_many :members` - Relationship to Members through MemberGroup ## Constraints - Name must be unique (case-insensitive, using LOWER(name) in database) - Slug must be unique (case-sensitive, exact match) - Name cannot be null - Slug cannot be null ## Calculations - `member_count` - Returns the number of members in this group ## Examples # Create a new group Group.create!(%{name: "Board Members", description: "Members of the board"}) # => %Group{name: "Board Members", slug: "board-members", ...} # Update group (slug remains unchanged) group = Group.get_by_slug!("board-members") Group.update!(group, %{description: "Updated description"}) # => %Group{slug: "board-members", ...} # slug unchanged! """ use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer require Ash.Query import Ash.Expr alias Mv.Helpers alias Mv.Helpers.SystemActor require Logger postgres do table "groups" repo Mv.Repo end actions do defaults [:read, :destroy] create :create do accept [:name, :description] change Mv.Membership.Changes.GenerateSlug validate string_length(:slug, min: 1) end update :update do accept [:name, :description] require_atomic? false end end validations do validate present(:name) # Case-insensitive name uniqueness validation validate fn changeset, _context -> name = Ash.Changeset.get_attribute(changeset, :name) current_id = Ash.Changeset.get_attribute(changeset, :id) if name do check_name_uniqueness(name, current_id) else :ok end end end attributes do uuid_v7_primary_key :id attribute :name, :string do allow_nil? false public? true constraints max_length: 100, trim?: true end attribute :slug, :string do allow_nil? false public? true writable? false constraints max_length: 100, trim?: true end attribute :description, :string do allow_nil? true public? true constraints max_length: 500, trim?: true end timestamps() end relationships do has_many :member_groups, Mv.Membership.MemberGroup many_to_many :members, Mv.Membership.Member, through: Mv.Membership.MemberGroup end calculations do calculate :member_count, :integer do description "Number of members in this group" calculation fn [group], _context -> system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) query = Mv.Membership.MemberGroup |> Ash.Query.filter(group_id == ^group.id) case Ash.read(query, opts) do {:ok, member_groups} -> [length(member_groups)] {:error, _} -> [0] end end end end identities do identity :unique_slug, [:slug] end # Private helper function for case-insensitive name uniqueness check defp check_name_uniqueness(name, exclude_id) do query = Mv.Membership.Group |> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name)) |> maybe_exclude_id(exclude_id) system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) case Ash.read(query, opts) do {:ok, []} -> :ok {:ok, _} -> {:error, field: :name, message: "has already been taken", value: name} {:error, reason} -> Logger.warning( "Name uniqueness validation query failed for group name '#{name}': #{inspect(reason)}. Allowing operation to proceed (fail-open)." ) :ok end end defp maybe_exclude_id(query, nil), do: query defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) end