- Group/MemberGroup/MembershipFeeType/MembershipFeeCycle: HasPermission policy - normal_user: Group and MembershipFeeCycle create/update/destroy; pages /groups/new, /groups/:slug/edit - Add policy tests for all four resources
166 lines
4.4 KiB
Elixir
166 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,
|
|
authorizers: [Ash.Policy.Authorizer]
|
|
|
|
require Ash.Query
|
|
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
|
|
|
|
policies do
|
|
policy action_type([:read, :create, :update, :destroy]) do
|
|
description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)"
|
|
authorize_if Mv.Authorization.Checks.HasPermission
|
|
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, context)
|
|
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
|
|
|
|
aggregates do
|
|
count :member_count, :member_groups
|
|
end
|
|
|
|
identities do
|
|
identity :unique_slug, [:slug]
|
|
end
|
|
|
|
# Private helper function for case-insensitive name uniqueness check
|
|
# Uses context actor if available (respects policies), falls back to system actor
|
|
defp check_name_uniqueness(name, exclude_id, context) do
|
|
# Use context actor if available (respects user permissions), otherwise fall back to system actor
|
|
actor =
|
|
case context do
|
|
%{actor: actor} when not is_nil(actor) -> actor
|
|
_ -> SystemActor.get_system_actor()
|
|
end
|
|
|
|
query =
|
|
Mv.Membership.Group
|
|
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
|
|> Helpers.query_exclude_id(exclude_id)
|
|
|
|
opts = Helpers.ash_actor_opts(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
|
|
end
|