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] 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 only) then HasPermission for :all; # create/destroy use HasPermission (normal_user + admin only). # Single check: own_data gets filter via auto_filter; admin does not match, gets :all from HasPermission. policies do bypass action_type(:read) do description "own_data: read only member_groups where member_id == actor.member_id" authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData 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