Skip to main content

lib/access_grid/access_passes.ex

defmodule AccessGrid.AccessPasses do
  @moduledoc """
  Manages access-pass lifecycle operations against `/v1/key-cards`.

  All functions accept an optional `:client` in opts. If not provided,
  credentials are loaded from application config via Gestalt.

  ## Examples

      # Using explicit client
      client = AccessGrid.Client.new(account_id: "...", api_secret: "...")
      AccessGrid.AccessPasses.issue(%{card_template_id: "tmpl_123"}, client: client)

      # Using config (client resolved automatically)
      AccessGrid.AccessPasses.get("card_abc123")

  """

  alias AccessGrid.AccessPass
  alias AccessGrid.Client
  alias AccessGrid.HttpFailure
  alias AccessGrid.HttpResponse
  alias AccessGrid.Params
  alias AccessGrid.Types

  @base_path "/v1/key-cards"

  @type result :: {:ok, AccessPass.t()} | {:error, Types.api_error_reason(), HttpFailure.t() | [atom(), ...]}
  @type list_result :: {:ok, [AccessPass.t()]} | {:error, Types.api_error_reason(), HttpFailure.t() | [atom(), ...]}

  @doc """
  Issues a new key card.

  ## Parameters

    * `params` - Map with card parameters (card_template_id, full_name, etc.)
    * `opts` - Options including `:client`

  ## Examples

      AccessGrid.AccessPasses.issue(%{
        card_template_id: "tmpl_123",
        full_name: "John Doe"
      })

  """
  @spec issue(map(), keyword()) :: result()
  def issue(params, opts \\ []) do
    with :ok <- Params.require(params, [:card_template_id]) do
      opts[:client]
      |> Client.request(:post, @base_path, body: params)
      |> handle_response()
    end
  end

  @doc """
  Retrieves a key card by ID.

  ## Examples

      AccessGrid.AccessPasses.get("card_abc123")

  """
  @spec get(String.t(), keyword()) :: result()
  def get(card_id, opts \\ []) do
    with :ok <- Params.require_present(card_id, :card_id) do
      opts[:client]
      |> Client.request(:get, "#{@base_path}/#{card_id}")
      |> handle_response()
    end
  end

  @doc """
  Updates a key card.

  ## Examples

      AccessGrid.AccessPasses.update("card_abc123", %{full_name: "New Name"})

  """
  @spec update(String.t(), map(), keyword()) :: result()
  def update(card_id, params, opts \\ []) do
    with :ok <- Params.require_present(card_id, :card_id) do
      opts[:client]
      |> Client.request(:patch, "#{@base_path}/#{card_id}", body: params)
      |> handle_response()
    end
  end

  @doc """
  Lists key cards for a template.

  ## Options

    * `:state` - Filter by card state (e.g., "active", "suspended")
    * `:client` - Client for authentication

  ## Examples

      AccessGrid.AccessPasses.list("tmpl_123")
      AccessGrid.AccessPasses.list("tmpl_123", state: "active")

  """
  @spec list(String.t(), keyword()) :: list_result()
  def list(template_id, opts \\ []) do
    with :ok <- Params.require_present(template_id, :template_id) do
      params =
        %{"template_id" => template_id}
        |> maybe_add_param("state", opts[:state])

      opts[:client]
      |> Client.request(:get, @base_path, params: params)
      |> handle_list_response()
    end
  end

  @doc """
  Suspends a key card.

  ## Examples

      AccessGrid.AccessPasses.suspend("card_abc123")

  """
  @spec suspend(String.t(), keyword()) :: result()
  def suspend(card_id, opts \\ []), do: manage_state(card_id, "suspend", opts)

  @doc """
  Resumes a suspended key card.

  ## Examples

      AccessGrid.AccessPasses.resume("card_abc123")

  """
  @spec resume(String.t(), keyword()) :: result()
  def resume(card_id, opts \\ []), do: manage_state(card_id, "resume", opts)

  @doc """
  Unlinks a key card from its device.

  ## Examples

      AccessGrid.AccessPasses.unlink("card_abc123")

  """
  @spec unlink(String.t(), keyword()) :: result()
  def unlink(card_id, opts \\ []), do: manage_state(card_id, "unlink", opts)

  @doc """
  Deletes a key card.

  ## Examples

      AccessGrid.AccessPasses.delete("card_abc123")

  """
  @spec delete(String.t(), keyword()) :: result()
  def delete(card_id, opts \\ []), do: manage_state(card_id, "delete", opts)

  # --- Private helpers ---

  defp manage_state(card_id, action, opts) do
    with :ok <- Params.require_present(card_id, :card_id) do
      opts[:client]
      |> Client.request(:post, "#{@base_path}/#{card_id}/#{action}")
      |> handle_response()
    end
  end

  defp handle_response({:ok, %HttpResponse{body_decoded: body}}) do
    {:ok, AccessPass.from_response(body)}
  end

  defp handle_response({:error, %HttpFailure{} = failure}) do
    {:error, reason_from_failure(failure), failure}
  end

  defp handle_list_response({:ok, %HttpResponse{body_decoded: body}}) do
    cards =
      body
      |> Map.get("keys", [])
      |> Enum.map(&AccessPass.from_response/1)

    {:ok, cards}
  end

  defp handle_list_response({:error, %HttpFailure{} = failure}) do
    {:error, reason_from_failure(failure), failure}
  end

  defp reason_from_failure(%HttpFailure{reason: :unauthorized}), do: :unauthorized
  defp reason_from_failure(%HttpFailure{reason: :forbidden}), do: :forbidden
  defp reason_from_failure(%HttpFailure{reason: :not_found}), do: :not_found
  defp reason_from_failure(%HttpFailure{reason: :unprocessable_entity}), do: :validation_failed
  defp reason_from_failure(%HttpFailure{reason: :too_many_requests}), do: :rate_limited
  defp reason_from_failure(%HttpFailure{reason: :timeout}), do: :timeout

  defp reason_from_failure(%HttpFailure{reason: reason})
       when reason in [:internal_server_error, :bad_gateway, :service_unavailable, :gateway_timeout, :server_error],
       do: :server_error

  defp reason_from_failure(%HttpFailure{}), do: :request_failed

  defp maybe_add_param(params, _key, nil), do: params
  defp maybe_add_param(params, key, value), do: Map.put(params, key, value)
end