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