defmodule Membership.Member do
@moduledoc """
Member is a main actor for determining features
"""
use Membership.Schema
import Ecto.Query
alias Membership.Repo
alias Membership.Plan
alias Membership.Feature
alias Membership.Role
alias Membership.MemberPlans
alias Membership.MemberFeatures
alias Membership.MemberRoles
alias Membership.Member
@typedoc "A member struct"
@type t :: %Member{}
@default_alphabet Enum.concat([?0..?9, ?A..?Z, ?a..?z])
@default_membership_identifier_size 8
schema "membership_members" do
field(:features, {:array, :string}, default: [])
field(:identifier, :string, default: nil)
many_to_many(:plans, Plan,
join_through: MemberPlans,
on_replace: :delete
)
many_to_many(:extra_features, Feature,
join_through: MemberFeatures,
on_replace: :delete
)
many_to_many(:roles, Role,
join_through: MemberRoles,
on_replace: :delete
)
timestamps()
end
def changeset(%Member{} = struct, params \\ %{}) do
struct
|> cast(params, [:identifier, :features])
|> generate_identifier()
|> cast_assoc(:plans, required: false)
|> cast_assoc(:roles, required: false)
|> cast_assoc(:extra_features, required: false)
end
def generate_identifier(changeset) do
size = Membership.Config.get(:membership_identifier_size, @default_membership_identifier_size)
alphabet = Membership.Config.get(:membership_identifier_alphabet, @default_alphabet)
changeset |> put_change(:identifier, Nanoid.generate(size, alphabet))
end
@doc """
Grant given grant type to a member.
## Examples
Function accepts either `Membership.Feature` or `Membership.Plan` grants.
Function is merging existing grants with the new ones, so calling grant with same
grants will not duplicate entries in table.
To grant particular feature to a given member
iex> Membership.Member.grant(%Membership.Member{id: 1}, %Membership.Feature{id: 1})
To grant particular plan to a given member
iex> Membership.Member.grant(%Membership.Member{id: 1}, %Membership.Plan{id: 1})
"""
@spec grant(Member.t(), Feature.t() | Plan.t()) :: Member.t()
def grant(%Member{id: id} = _member, %Plan{id: plan_id} = _plan) do
member = Member |> Repo.get!(id)
plan = Plan |> Repo.get!(plan_id)
revoke(member, plan)
%MemberPlans{member_id: member.id, plan_id: plan.id}
|> Repo.insert()
sync_features(member)
end
def grant(%{member: %Member{id: _pid} = member}, %Plan{id: _id} = plan) do
grant(member, plan)
end
def grant(%{member_id: id}, %Plan{id: _id} = plan) do
%Member{id: id}
|> grant(plan)
end
def grant(%Member{id: id} = _member, %Role{id: role_id} = _role) do
member = Member |> Repo.get!(id)
role = Role |> Repo.get!(role_id)
revoke(member, role)
%MemberRoles{member_id: member.id, role_id: role.id}
|> Repo.insert()
sync_features(member)
end
def grant(%{member: %Member{id: _pid} = member}, %Role{id: _id} = role) do
grant(member, role)
end
def grant(%{member_id: id}, %Role{id: _id} = role) do
%Member{id: id}
|> grant(role)
end
def grant(_, _), do: raise(ArgumentError, message: "Bad arguments for giving grant")
def grant(%Member{id: id} = _member, %Feature{id: feature_id} = _feature, permission) do
member = Member |> Repo.get!(id)
feature = Feature |> Repo.get(feature_id)
%MemberFeatures{member_id: member.id, feature_id: feature.id, permission: permission}
|> Repo.insert()
sync_features(member)
end
def grant(%{member: member}, %Feature{id: _id} = feature, permission) do
member
|> grant(feature, permission)
end
def grant(%{member_id: id}, %Feature{id: _id} = feature, permission) do
%Member{id: id}
|> grant(feature, permission)
end
def grant(_, _, _), do: raise(ArgumentError, message: "Bad arguments for giving grant")
@doc """
Revoke given grant type from a member.
## Examples
Function accepts either `Membership.Feature` or `Membership.Plan` grants.
Function is directly opposite of `Membership.Member.grant/2`
To revoke particular feature from a given member
iex> Membership.Member.revoke(%Membership.Member{id: 1}, %Membership.Feature{id: 1})
To revoke particular plan from a given member
iex> Membership.Member.revoke(%Membership.Member{id: 1}, %Membership.Plan{id: 1})
"""
@spec revoke(Member.t(), Feature.t() | Plan.t()) :: Member.t()
def revoke(%Member{id: id} = _member, %Plan{id: _id} = plan) do
from(pa in MemberPlans)
|> where([pr], pr.member_id == ^id and pr.plan_id == ^plan.id)
|> Repo.delete_all()
end
def revoke(%{member: %Member{id: _pid} = member}, %Plan{id: _id} = plan) do
revoke(member, plan)
end
def revoke(%{member_id: id}, %Plan{id: _id} = plan) do
revoke(%Member{id: id}, plan)
end
def revoke(%Member{id: id} = _member, %Role{id: _id} = role) do
from(pa in MemberRoles)
|> where([pr], pr.member_id == ^id and pr.role_id == ^role.id)
|> Repo.delete_all()
end
def revoke(%{member: %Member{id: _pid} = member}, %Role{id: _id} = role) do
revoke(member, role)
end
def revoke(%{member_id: id}, %Role{id: _id} = role) do
revoke(%Member{id: id}, role)
end
def revoke(
%{member: %Member{id: _pid} = member},
%Feature{id: _id} = feature
) do
revoke(member, feature)
end
def revoke(%{member_id: id}, %Feature{id: _id} = feature) do
revoke(%Member{id: id}, feature)
end
def revoke(%Member{id: id} = _member, %Feature{id: _id} = feature) do
member = Member |> Repo.get!(id)
features =
Enum.filter(member.features, fn grant ->
grant != feature.identifier
end)
changeset(member)
|> put_change(:features, features)
|> Repo.update!()
end
def revoke(_, _), do: raise(ArgumentError, message: "Bad arguments for revoking grant")
def revoke(_, _, _), do: raise(ArgumentError, message: "Bad arguments for revoking grant")
def load_member_feature(member, %{
__struct__: _feature_name,
id: feature_id,
identifier: _identifier
}) do
MemberFeatures
|> where(
[e],
e.member_id == ^member.id and e.feature_id == ^feature_id
)
|> Repo.one()
end
def load_member_plan(member, %{
__struct__: _plan_name,
id: plan_id,
identifier: _identifier
}) do
MemberPlans
|> where(
[e],
e.member_id == ^member.id and e.plan_id == ^plan_id
)
|> Repo.one()
end
def load_member_role(member, %{
__struct__: _role_name,
id: role_id,
identifier: _identifier
}) do
MemberRoles
|> where(
[e],
e.member_id == ^member.id and e.role_id == ^role_id
)
|> Repo.one()
end
@doc """
Sync features column with the member features and member plans pivot tables.
we do this for caching reasons, ie holding the plan[feature] and extra feature identifiers summed
into a list and stored in features column of the member, we query this to see if member has
ex feature vs repo lookup by plan and checking if plan has said feature
"""
@spec sync_features(Member.t()) :: Member.t()
def sync_features(%Member{id: id} = _member) do
member =
Member
|> Repo.get!(id)
|> Repo.preload(plans: :features)
|> Repo.preload(roles: :features)
|> Repo.preload(:extra_features)
plan_features =
Enum.map(member.plans, fn x ->
x.features
|> Enum.map(fn f ->
f.identifier
end)
end)
|> List.flatten()
extra_features =
Enum.map(member.extra_features, fn x ->
x.identifier
end)
role_features =
Enum.map(member.roles, fn x ->
x.features
|> Enum.map(fn f ->
f.identifier
end)
end)
feature_removals = fetch_removed_features(member.id)
features =
List.flatten(
Enum.uniq(member.features ++ plan_features ++ role_features ++ extra_features) --
feature_removals
)
changeset(member)
|> put_change(:features, features)
|> Repo.update!()
end
def build(name) do
changeset(%Member{}, %{
name: name
})
|> Ecto.Changeset.apply_changes()
end
def create(name) do
changeset(%Member{}, %{
name: name
})
|> Repo.insert_or_update()
end
def table, do: :membership_members
def fetch_removed_features(id) do
Repo.all(
from(mf in MemberFeatures,
join: f in Feature,
on: mf.feature_id == f.id,
where: mf.member_id == ^id and mf.permission != "required",
select: f.identifier
)
)
end
end