mitgliederverwaltung/lib/membership/member_group.ex
Moritz 5889683854 Add resource policies for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle
- 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
2026-02-03 23:52:12 +01:00

160 lines
4.5 KiB
Elixir

defmodule Mv.Membership.MemberGroup do
@moduledoc """
Ash resource representing the join table for the many-to-many relationship
between Members and Groups.
## Overview
MemberGroup is a join table that links members to groups. It enables the
many-to-many relationship where:
- A member can belong to multiple groups
- A group can contain multiple members
## Attributes
- `member_id` - Foreign key to Member (required)
- `group_id` - Foreign key to Group (required)
## Relationships
- `belongs_to :member` - Relationship to Member
- `belongs_to :group` - Relationship to Group
## Constraints
- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships
- CASCADE delete: Removing member removes all group associations
- CASCADE delete: Removing group removes all member associations
## Examples
# Add member to group
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id})
# Remove member from group
{:ok, [member_group]} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
domain: Mv.Membership
)
:ok = Membership.destroy_member_group(member_group)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
import Ash.Expr
require Ash.Query
postgres do
table "member_groups"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:member_id, :group_id]
end
end
# Authorization: read uses bypass for :linked (own_data list) then HasPermission for :all;
# create/destroy use HasPermission (normal_user + admin only).
# Order: bypass first so own_data gets expr filter; HasPermission then authorizes :all for others.
policies do
bypass action_type(:read) do
description "own_data: read only member_groups where member_id == actor.member_id"
authorize_if expr(member_id == ^actor(:member_id))
end
policy action_type(:read) do
description "Check read permission from role (read_only/normal_user/admin :all)"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action_type([:create, :destroy]) do
description "Check create/destroy from role (normal_user + admin only)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
validate present(:member_id)
validate present(:group_id)
# Prevent duplicate associations
validate fn changeset, context ->
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
group_id = Ash.Changeset.get_attribute(changeset, :group_id)
current_id = Ash.Changeset.get_attribute(changeset, :id)
if member_id && group_id do
check_duplicate_association(member_id, group_id, current_id, context)
else
:ok
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :member_id, :uuid do
allow_nil? false
end
attribute :group_id, :uuid do
allow_nil? false
end
timestamps()
end
relationships do
belongs_to :member, Mv.Membership.Member do
allow_nil? false
end
belongs_to :group, Mv.Membership.Group do
allow_nil? false
end
end
identities do
identity :unique_member_group, [:member_id, :group_id]
end
# Private helper function to check for duplicate associations
# Uses context actor if available (respects policies), falls back to system actor
defp check_duplicate_association(member_id, group_id, exclude_id, context) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
# 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.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|> Helpers.query_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :member_id, message: "Member is already in this group", value: member_id}
{:error, _reason} ->
# Fail-open: if query fails, allow operation to proceed
# Database constraint will catch duplicates anyway
:ok
end
end
end