Skip to main content

lib/miosa/admin.ex

defmodule Miosa.Admin do
  @moduledoc """
  Admin surface — `/api/v1/admin/*`.

  Requires an admin credential: a `msk_a_*` / `msk_p_*` API key or an admin
  JWT. Calls from a user-role credential return `{:error, %Miosa.Error{status: 403}}`.

  For endpoints not covered by the typed helpers below, use `request/5`
  which accepts an arbitrary method + path.

  ## Example

      client = Miosa.client("msk_a_...")

      {:ok, _} = Miosa.Admin.grant_credits(client, "tenant-uuid", 1000, "goodwill")
      {:ok, users} = Miosa.Admin.list_users(client, limit: 50, status: "active")
      {:ok, _} = Miosa.Admin.change_tenant_plan(client, "tenant-uuid", "pro")

  """

  alias Miosa.Client

  @type result :: {:ok, map()} | {:error, Miosa.Error.t()}

  # ── Escape hatch ────────────────────────────────────────────────────────────

  @doc """
  Call any admin endpoint directly.

  `method` is one of `:get`, `:post`, `:put`, `:patch`, `:delete`.
  `path` is relative to `/api/v1` and should include the `/admin` prefix.
  """
  @spec request(Client.t(), atom(), String.t(), map() | nil, keyword()) :: result
  def request(client, method, path, body \\ nil, opts \\ [])

  def request(client, :get, path, _body, opts), do: Client.get(client, path, opts)
  def request(client, :post, path, body, opts), do: Client.post(client, path, body, opts)
  def request(client, :put, path, body, opts), do: Client.put(client, path, body, opts)
  def request(client, :patch, path, body, opts), do: Client.patch(client, path, body, opts)
  def request(client, :delete, path, _body, opts), do: Client.delete(client, path, opts)

  # ── Overview ────────────────────────────────────────────────────────────────

  @spec dashboard(Client.t()) :: result
  def dashboard(client), do: Client.get(client, "/admin/dashboard")

  @spec stats(Client.t()) :: result
  def stats(client), do: Client.get(client, "/admin/stats")

  @doc """
  Read the platform audit log.

  Options: `:limit`, `:cursor`.
  """
  @spec audit_log(Client.t(), keyword()) :: result
  def audit_log(client, opts \\ []) do
    Client.get(client, "/admin/audit-log", params: pick(opts, [:limit, :cursor]))
  end

  @spec billing_summary(Client.t()) :: result
  def billing_summary(client), do: Client.get(client, "/admin/billing")

  @spec detailed_health(Client.t()) :: result
  def detailed_health(client), do: Client.get(client, "/admin/health/detailed")

  # ── Credits ─────────────────────────────────────────────────────────────────

  @spec grant_credits(Client.t(), String.t(), integer(), String.t(), keyword()) :: result
  def grant_credits(client, tenant_id, amount, description, opts \\ [])
      when is_integer(amount) and amount > 0 do
    body =
      %{tenant_id: tenant_id, amount: amount, description: description}
      |> maybe_put(:expires_at, Keyword.get(opts, :expires_at))

    Client.post(client, "/admin/credits/grant", body)
  end

  @spec deduct_credits(Client.t(), String.t(), integer(), String.t()) :: result
  def deduct_credits(client, tenant_id, amount, description)
      when is_integer(amount) and amount > 0 do
    Client.post(client, "/admin/credits/deduct", %{
      tenant_id: tenant_id,
      amount: amount,
      description: description
    })
  end

  @spec refund_credits(Client.t(), String.t(), integer(), String.t(), keyword()) :: result
  def refund_credits(client, tenant_id, amount, description, opts \\ []) do
    body =
      %{tenant_id: tenant_id, amount: amount, description: description}
      |> maybe_put(:transaction_id, Keyword.get(opts, :transaction_id))

    Client.post(client, "/admin/credits/refund", body)
  end

  @spec tenant_balance(Client.t(), String.t()) :: result
  def tenant_balance(client, tenant_id) do
    Client.get(client, "/admin/credits/#{tenant_id}/balance")
  end

  @spec tenant_credit_history(Client.t(), String.t(), keyword()) :: result
  def tenant_credit_history(client, tenant_id, opts \\ []) do
    Client.get(client, "/admin/credits/#{tenant_id}/history",
      params: pick(opts, [:limit, :cursor])
    )
  end

  # ── Users ───────────────────────────────────────────────────────────────────

  @doc """
  List users.

  Options: `:limit`, `:cursor`, `:q`, `:status` (`"active" | "suspended" | "deleted"`).
  """
  @spec list_users(Client.t(), keyword()) :: result
  def list_users(client, opts \\ []) do
    Client.get(client, "/admin/users", params: pick(opts, [:limit, :cursor, :q, :status]))
  end

  @spec get_user(Client.t(), String.t()) :: result
  def get_user(client, user_id), do: Client.get(client, "/admin/users/#{user_id}")

  @spec update_user(Client.t(), String.t(), map()) :: result
  def update_user(client, user_id, attrs) when is_map(attrs) do
    Client.put(client, "/admin/users/#{user_id}", attrs)
  end

  @spec delete_user(Client.t(), String.t()) :: result
  def delete_user(client, user_id), do: Client.delete(client, "/admin/users/#{user_id}")

  @spec change_user_role(Client.t(), String.t(), String.t()) :: result
  def change_user_role(client, user_id, role) when role in ~w(user admin owner super_admin) do
    Client.post(client, "/admin/users/#{user_id}/role", %{role: role})
  end

  @spec force_logout(Client.t(), String.t()) :: result
  def force_logout(client, user_id) do
    Client.post(client, "/admin/users/#{user_id}/force-logout", nil)
  end

  @spec suspend_user(Client.t(), String.t(), keyword()) :: result
  def suspend_user(client, user_id, opts \\ []) do
    body = maybe_put(%{}, :reason, Keyword.get(opts, :reason))
    Client.post(client, "/admin/users/#{user_id}/suspend", if(body == %{}, do: nil, else: body))
  end

  @spec unsuspend_user(Client.t(), String.t()) :: result
  def unsuspend_user(client, user_id) do
    Client.post(client, "/admin/users/#{user_id}/unsuspend", nil)
  end

  @spec ban_user(Client.t(), String.t(), String.t(), keyword()) :: result
  def ban_user(client, user_id, reason, opts \\ []) do
    body = maybe_put(%{reason: reason}, :expires_at, Keyword.get(opts, :expires_at))
    Client.post(client, "/admin/users/#{user_id}/ban", body)
  end

  @spec unban_user(Client.t(), String.t()) :: result
  def unban_user(client, user_id) do
    Client.post(client, "/admin/users/#{user_id}/unban", nil)
  end

  @spec bulk_user_action(Client.t(), [String.t()], String.t(), keyword()) :: result
  def bulk_user_action(client, user_ids, action, opts \\ [])
      when is_list(user_ids) and action in ~w(suspend unsuspend delete tag notify) do
    body =
      %{user_ids: user_ids, action: action}
      |> maybe_put(:params, Keyword.get(opts, :params))

    Client.post(client, "/admin/users/bulk", body)
  end

  # ── Tenants ─────────────────────────────────────────────────────────────────

  @spec list_tenants(Client.t(), keyword()) :: result
  def list_tenants(client, opts \\ []) do
    Client.get(client, "/admin/tenants", params: pick(opts, [:limit, :cursor, :q]))
  end

  @spec tenant_detail(Client.t(), String.t()) :: result
  def tenant_detail(client, tenant_id) do
    Client.get(client, "/admin/tenants/#{tenant_id}/detail")
  end

  @spec suspend_tenant(Client.t(), String.t(), keyword()) :: result
  def suspend_tenant(client, tenant_id, opts \\ []) do
    body = maybe_put(%{}, :reason, Keyword.get(opts, :reason))

    Client.post(
      client,
      "/admin/tenants/#{tenant_id}/suspend",
      if(body == %{}, do: nil, else: body)
    )
  end

  @spec unsuspend_tenant(Client.t(), String.t()) :: result
  def unsuspend_tenant(client, tenant_id) do
    Client.post(client, "/admin/tenants/#{tenant_id}/unsuspend", nil)
  end

  @spec change_tenant_plan(Client.t(), String.t(), String.t(), keyword()) :: result
  def change_tenant_plan(client, tenant_id, plan, opts \\ [])
      when plan in ~w(free starter pro scale) do
    body = %{plan: plan, prorate: Keyword.get(opts, :prorate, true)}
    Client.post(client, "/admin/tenants/#{tenant_id}/plan", body)
  end

  @spec delete_tenant(Client.t(), String.t()) :: result
  def delete_tenant(client, tenant_id) do
    Client.delete(client, "/admin/tenants/#{tenant_id}")
  end

  # ── Computers ───────────────────────────────────────────────────────────────

  @doc """
  List all computers across tenants.

  Options: `:limit`, `:cursor`, `:status`, `:tenant_id`.
  """
  @spec list_computers(Client.t(), keyword()) :: result
  def list_computers(client, opts \\ []) do
    Client.get(client, "/admin/computers",
      params: pick(opts, [:limit, :cursor, :status, :tenant_id])
    )
  end

  @spec delete_computer(Client.t(), String.t()) :: result
  def delete_computer(client, computer_id) do
    Client.delete(client, "/admin/computers/#{computer_id}")
  end

  @spec suspend_computer(Client.t(), String.t()) :: result
  def suspend_computer(client, computer_id) do
    Client.post(client, "/admin/computers/#{computer_id}/suspend", nil)
  end

  @spec resume_computer(Client.t(), String.t()) :: result
  def resume_computer(client, computer_id) do
    Client.post(client, "/admin/computers/#{computer_id}/resume", nil)
  end

  @spec restart_computer(Client.t(), String.t()) :: result
  def restart_computer(client, computer_id) do
    Client.post(client, "/admin/computers/#{computer_id}/restart", nil)
  end

  @spec purge_stale_computers(Client.t()) :: result
  def purge_stale_computers(client) do
    Client.post(client, "/admin/computers/purge-stale", nil)
  end

  # ── API Keys ────────────────────────────────────────────────────────────────

  @doc """
  List API keys across tenants.

  Options: `:limit`, `:cursor`, `:tenant_id`, `:status` (`"active" | "revoked" | "expired"`).
  """
  @spec list_api_keys(Client.t(), keyword()) :: result
  def list_api_keys(client, opts \\ []) do
    Client.get(client, "/admin/api-keys",
      params: pick(opts, [:limit, :cursor, :tenant_id, :status])
    )
  end

  @doc """
  Create an API key on behalf of a tenant/user.

  Required options: `:name`, `:tenant_id`, `:user_id`.
  Optional: `:key_type` (default `"user"`), `:purpose` (default `"api"`),
  `:rate_limit_rpm`, `:expires_at`, `:allowed_ips`.
  """
  @spec create_api_key(Client.t(), keyword()) :: result
  def create_api_key(client, opts) do
    body =
      %{
        name: Keyword.fetch!(opts, :name),
        tenant_id: Keyword.fetch!(opts, :tenant_id),
        user_id: Keyword.fetch!(opts, :user_id),
        key_type: Keyword.get(opts, :key_type, "user"),
        purpose: Keyword.get(opts, :purpose, "api")
      }
      |> maybe_put(:rate_limit_rpm, Keyword.get(opts, :rate_limit_rpm))
      |> maybe_put(:expires_at, Keyword.get(opts, :expires_at))
      |> maybe_put(:allowed_ips, Keyword.get(opts, :allowed_ips))

    Client.post(client, "/admin/api-keys", body)
  end

  @spec api_key_stats(Client.t()) :: result
  def api_key_stats(client), do: Client.get(client, "/admin/api-keys/stats")

  @spec bulk_revoke_api_keys(Client.t(), [String.t()]) :: result
  def bulk_revoke_api_keys(client, key_ids) when is_list(key_ids) do
    Client.post(client, "/admin/api-keys/bulk-revoke", %{key_ids: key_ids})
  end

  @spec revoke_api_key(Client.t(), String.t()) :: result
  def revoke_api_key(client, key_id) do
    Client.delete(client, "/admin/api-keys/#{key_id}")
  end

  # ── Optimal ─────────────────────────────────────────────────────────────────

  @spec optimal_status(Client.t()) :: result
  def optimal_status(client), do: Client.get(client, "/admin/optimal/status")

  @spec list_optimal_models(Client.t()) :: result
  def list_optimal_models(client), do: Client.get(client, "/admin/optimal/models")

  @spec switch_optimal_model(Client.t(), String.t()) :: result
  def switch_optimal_model(client, model_id) do
    Client.post(client, "/admin/optimal/models/switch", %{model_id: model_id})
  end

  # ── Helpers ─────────────────────────────────────────────────────────────────

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  defp pick(opts, keys) do
    opts
    |> Keyword.take(keys)
    |> Enum.reject(fn {_, v} -> is_nil(v) end)
  end
end