Skip to main content

lib/billdog_eng/surveys.ex

defmodule BilldogEng.Surveys do
  @moduledoc """
  Surveys DATA API (no UI rendering). Wraps the `bdsurvey-*` edge functions.

  Lifecycle: `list` → `fetch` → `start` → `record_partial`* → `submit`
  (or `abandon`). The same `:idempotency_key` SHOULD be carried across `start`
  and `submit` so retries are safe.

  Answer shape: `%{question_id: ..., choice_id?: ..., answer_text?: ...,
  answer_number?: ..., answer_json?: ...}` (string or atom keys accepted).

  Mirrors the Node SDK `surveys.ts`. Each function takes a `%BilldogEng{}`
  client and returns `{:ok, result}` or `{:error, %BilldogEng.Error{}}`.
  """

  alias BilldogEng.Transport

  @type client :: BilldogEng.t()
  @type result :: {:ok, term()} | {:error, BilldogEng.Error.t()}

  @doc "List active surveys eligible for the (optionally identified) user."
  @spec list(client(), keyword()) :: {:ok, [map()]} | {:error, BilldogEng.Error.t()}
  def list(client, opts \\ []) do
    body =
      %{api_key: client.api_key}
      |> put_if(:customer_id, Keyword.get(opts, :distinct_id))
      |> put_if(:anonymous_id, Keyword.get(opts, :anonymous_id))

    case req(client, "/bdsurvey-list", body) do
      {:ok, data} when is_map(data) -> {:ok, Map.get(data, "surveys") || []}
      {:ok, _} -> {:ok, []}
      {:error, err} -> {:error, err}
    end
  end

  @doc "Fetch the full configuration for a single survey."
  @spec fetch(client(), String.t(), keyword()) :: result()
  def fetch(client, survey_id, opts \\ []) do
    body =
      %{api_key: client.api_key, survey_id: survey_id}
      |> put_if(:customer_id, Keyword.get(opts, :distinct_id))
      |> put_if(:anonymous_id, Keyword.get(opts, :anonymous_id))

    req(client, "/bdsurvey-fetch", body)
  end

  @doc "Begin a survey response. Returns `{:ok, %{\"respondent_id\" => ...}}`."
  @spec start(client(), String.t(), keyword()) :: result()
  def start(client, survey_id, context \\ []) do
    req(client, "/bdsurvey-submit", build_body("start", client, survey_id, nil, context))
  end

  @doc "Progressively persist a partial set of answers under an in-progress respondent."
  @spec record_partial(client(), String.t(), String.t(), [map()], keyword()) :: result()
  def record_partial(client, survey_id, respondent_id, answers, context \\ []) do
    context = Keyword.put(context, :respondent_id, respondent_id)
    req(client, "/bdsurvey-submit", build_body("partial", client, survey_id, answers, context))
  end

  @doc "Submit the final answers, completing the response."
  @spec submit(client(), String.t(), [map()], keyword()) :: result()
  def submit(client, survey_id, answers, context \\ []) do
    req(client, "/bdsurvey-submit", build_body("submit", client, survey_id, answers, context))
  end

  @doc "Mark an in-progress response as abandoned."
  @spec abandon(client(), String.t(), String.t()) :: result()
  def abandon(client, survey_id, respondent_id) do
    body = build_body("abandon", client, survey_id, nil, respondent_id: respondent_id)
    req(client, "/bdsurvey-submit", body)
  end

  # ── Internals ────────────────────────────────────────────────────────────────

  defp req(client, path, body) do
    Transport.request(client.transport,
      path: path,
      body: body,
      headers: [{"x-api-key", client.api_key}],
      gzip: false
    )
  end

  defp build_body(action, client, survey_id, answers, context) do
    %{action: action, api_key: client.api_key, survey_id: survey_id}
    |> put_if(:answers, answers && Enum.map(answers, &normalize_answer/1))
    |> put_if(:respondent_id, Keyword.get(context, :respondent_id))
    |> put_if(:customer_id, Keyword.get(context, :customer_id))
    |> put_if(:anonymous_id, Keyword.get(context, :anonymous_id))
    |> put_if(:session_id, Keyword.get(context, :session_id))
    |> put_if(:platform, Keyword.get(context, :platform))
    |> put_if(:device_info, Keyword.get(context, :device_info))
    |> put_if(:duration_ms, Keyword.get(context, :duration_ms))
    |> put_if(:context, Keyword.get(context, :context))
    |> put_if(:collector_id, Keyword.get(context, :collector_id))
    |> put_if(:collector_type, Keyword.get(context, :collector_type))
    |> put_if(:idempotency_key, Keyword.get(context, :idempotency_key))
  end

  # Accept atom- or string-keyed answers; ship them on the wire with string keys.
  defp normalize_answer(answer) when is_map(answer) do
    Map.new(answer, fn {k, v} -> {to_string(k), v} end)
  end

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