lib/membership/plan.ex

defmodule Membership.Plan do
  @moduledoc """
  Plan is main representation of a single plan
  """
  use Membership.Schema
  import Ecto.Query

  alias Membership.Feature
  alias Membership.Plan
  alias Membership.Plan.Server
  alias Membership.PlanFeatures

  @typedoc "A plan struct"
  @type t :: %Plan{}

  @params ~w(identifier name)a
  @required_fields ~w(identifier name)a

  schema "membership_plans" do
    field(:identifier, :string)
    field(:name, :string)

    many_to_many(:features, Feature,
      join_through: PlanFeatures,
      on_replace: :delete
    )
  end

  def changeset(%Plan{} = struct, params = %Plan{}) do
    params = %{id: params.id, identifier: params.identifier, name: params.name}
    changeset(struct, params)
  end

  def changeset(%Plan{} = struct, params) do
    struct
    |> cast(params, @params)
    |> cast_assoc(:features, required: false)
    |> validate_required(@required_fields)
    |> unique_constraint(:identifier, message: "Plan already exists")
  end

  def build(identifier, name, features \\ []) do
    changeset(%Plan{}, %{
      identifier: identifier,
      name: name,
      features: features
    })
    |> Ecto.Changeset.apply_changes()
  end

  def create(identifier, name, features \\ []) do
    plan =
      changeset(%Plan{}, %{
        identifier: identifier,
        name: name
      })
      |> Repo.insert_or_update()

    Enum.map(features, fn f ->
      Feature.create(f.identifier, f.name)
      |> Plan.grant(plan)
    end)
  end

  def create(plan = %Plan{}) do
    create(plan.identifier, plan.name, plan.features)
  end

  def table, do: :membership_plans

  @doc """
  Grant given grant type to a feature.

  ## Examples

  Function accepts either `Membership.Plan` or `Membership.Feature` 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 plan

      iex> Membership.Plan.grant(%Membership.Feature{id: 1}, %Membership.Plan{id: 1})

  To grant particular feature to a given plan

      iex> Membership.Plan.grant(%Membership.Plan{id: 1}, %Membership.Feature{id: 1})

  """

  @spec grant(Plan.t(), Plan.t() | Feature.t()) :: Member.t()
  def grant(%Plan{id: id} = _plan, %Feature{id: feature_id} = _feature) do
    # Preload Plan features
    plan = Plan |> Repo.get!(id)
    feature = Feature |> Repo.get!(feature_id)

    revoke(feature, plan)

    %PlanFeatures{plan_id: plan.id, feature_id: feature.id}
    |> Repo.insert()

    Server.reload()
  end

  def grant(%{plan: %Plan{id: _pid} = plan}, %Feature{id: _id} = feature) do
    grant(plan, feature)
  end

  def grant(%{plan_id: id}, %Feature{id: _id} = feature) do
    Plan
    |> Repo.get!(id)
    |> grant(feature)
  end

  def grant(%Feature{id: feature_id} = _feature, %Plan{id: id} = _plan) do
    plan = Plan |> Repo.get!(id)
    feature = Feature |> Repo.get!(feature_id)

    revoke(feature, plan)

    %PlanFeatures{plan_id: plan.id, feature_id: feature.id}
    |> Repo.insert()

    Server.reload()
  end

  def grant(%{feature: feature}, %Plan{id: _id} = plan) do
    grant(plan, feature)
  end

  def grant(%{feature_id: id}, %Plan{id: _id} = plan) do
    grant(plan, %Feature{id: id})
  end

  def grant(_, _), do: raise(ArgumentError, message: "Bad arguments for giving grant")

  def grant(_, _, _), do: raise(ArgumentError, message: "Bad arguments for giving grant")

  @doc """
  Revoke given grant type from a member.

  ## Examples

  Function accepts either `Membership.Plan` or `Membership.Feature` grants.
  Function is directly opposite of `Membership.Member.grant/2`

  To revoke particular feature from a given plan

      iex> Membership.Plan.revoke(%Membership.Feature{id: 1}, %Membership.Plan{id: 1})

  To revoke particular plan from a given feature

      iex> Membership.Plan.revoke(%Membership.Plan{id: 1}, %Membership.Feature{id: 1})

  """
  @spec revoke(Plan.t(), Plan.t() | Feature.t()) :: Member.t()
  def revoke(%Plan{id: id} = _, %Feature{id: _id} = feature) do
    from(pa in PlanFeatures)
    |> where([pr], pr.plan_id == ^id and pr.feature_id == ^feature.id)
    |> Repo.delete_all()
  end

  def revoke(%{plan: %Plan{id: _pid} = plan}, %Feature{id: _id} = feature) do
    revoke(plan, feature)
  end

  def revoke(%{feature_id: id}, %Feature{id: _id} = feature) do
    revoke(%Plan{id: id}, feature)
  end

  def revoke(%Feature{id: id} = _, %Plan{id: _id} = plan) do
    from(pa in PlanFeatures)
    |> where([pr], pr.feature_id == ^id and pr.plan_id == ^plan.id)
    |> Repo.delete_all()
  end

  def revoke(
        %{feature: %Feature{id: _pid} = feature},
        %Plan{id: _id} = plan
      ) do
    revoke(plan, feature)
  end

  def revoke(%{feature_id: id}, %Plan{id: _id} = plan) do
    revoke(%Feature{id: id}, plan)
  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_plan_feature(plan, %{
        __struct__: _feature_name,
        id: feature_id,
        identifier: _identifier
      }) do
    FeaturePlans
    |> where(
      [e],
      e.plan_id == ^plan.id and e.feature_id == ^feature_id
    )
    |> Repo.one()
  end

  def all() do
    Repo.all(Membership.Plan)
    |> Repo.preload(:features)
    |> Enum.map(fn x ->
      features = Enum.map(x.features, fn f -> f.identifier end)
      {x.identifier, features}
    end)
  end
end