mitgliederverwaltung/lib/membership/group.ex
Simon 6db64bf996
Some checks failed
continuous-integration/drone/push Build is failing
feat: add groups resource #371
2026-01-27 16:03:21 +01:00

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