171 lines
4.4 KiB
Elixir
171 lines
4.4 KiB
Elixir
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
|