lib/sanity/sync.ex

defmodule Sanity.Sync do
  @moduledoc """
  For syncing content from Sanity CMS to Ecto.
  """

  import Ecto.Query
  alias Sanity.Sync.Doc
  import UnsafeAtomizeKeys

  defp repo, do: Application.fetch_env!(:sanity_sync, :repo)

  @doc """
  Gets a single document. Returns `nil` if document does not exist.
  """
  def get_doc(id) do
    case repo().get(Doc, id) do
      nil -> nil
      %Doc{doc: doc} -> unsafe_atomize_keys(doc)
    end
  end

  @doc """
  Gets a single document. Raises `Ecto.NoResultsError` if document does not exist.
  """
  def get_doc!(id) do
    repo().get!(Doc, id).doc |> unsafe_atomize_keys()
  end

  @doc """
  Fetches a single document from Sanity. If the document exists then `upsert_sanity_doc!/2` will
  be called. If the document doesn't exist, then the `Sanity.Sync.Doc` for that document will be
  deleted.

  This function can be called when a webhook is received to sync a document.

  ## Options

    * `callback` - Callback function that will be called after the document is upserted. It will
      be passed a map like `%{doc: doc, repo: repo}`. This callback is not called when the record
      is deleted.
    * `sanity_config` - Sanity configuration. See `Sanity.request/2`.
  """
  def sync(id, opts) when is_binary(id) do
    opts = Keyword.validate!(opts, [:callback, :sanity_config])

    """
    *[_id == $id]
    """
    |> Sanity.query(%{id: id})
    |> request!(Keyword.fetch!(opts, :sanity_config))
    |> Sanity.result!()
    |> case do
      [doc] -> doc |> unsafe_atomize_keys(&Inflex.underscore/1) |> upsert_sanity_doc!(opts)
      [] -> repo().delete_all(from d in Doc, where: d.id == ^id)
    end
  end

  @doc """
  Fetches all documents from Sanity and calls `upsert_sanity_doc!/2`.

  ## Options

    * `callback` - Callback function that will be called after each document is upserted. It will
      be passed a map like `%{doc: doc, repo: repo}`.
    * `sanity_config` - Sanity configuration. See `Sanity.request/2`.
    * `types` - List of types to sync. If omitted, all types will be synced.
  """
  def sync_all(opts) do
    opts = Keyword.validate!(opts, [:callback, :sanity_config, :types])

    """
    *[_type in $types && !(_id in path("drafts.**"))]
    """
    |> Sanity.query(%{types: Keyword.fetch!(opts, :types)})
    |> request!(Keyword.fetch!(opts, :sanity_config))
    |> Sanity.result!()
    |> Enum.map(fn doc -> unsafe_atomize_keys(doc, &Inflex.underscore/1) end)
    |> Enum.each(&upsert_sanity_doc!(&1, opts))

    # TODO paginate
  end

  @doc """
  Upserts a sanity document.
  """
  def upsert_sanity_doc!(%{_id: id, _type: type} = doc, opts \\ []) do
    Doc.changeset(%Doc{}, %{doc: doc, id: id, type: type})
    |> repo().insert!(conflict_target: :id, on_conflict: :replace_all)
    |> tap(fn _ ->
      case Keyword.fetch(opts, :callback) do
        {:ok, cb} -> cb.(%{doc: doc, repo: repo()})
        :error -> nil
      end
    end)
  end

  defp request!(request, config) do
    Application.get_env(:sanity_sync, :sanity_client, Sanity).request!(request, config)
  end
end