billdogeng 1.0.0-beta.1

Official BilldogEng server SDK for Rust — Analytics, Feature Flags (remote + local eval), Surveys, Messaging, and LLM observability.
Documentation
//! 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.

use serde_json::{json, Map, Value};

use crate::error::Result;
use crate::transport::{RequestOptions, Transport};
use crate::types::{SurveyAnswer, SurveyContext, SurveyListItem};

/// Survey lifecycle action.
#[derive(Debug, Clone, Copy)]
enum Action {
    Start,
    Partial,
    Submit,
    Abandon,
}

impl Action {
    fn as_str(&self) -> &'static str {
        match self {
            Action::Start => "start",
            Action::Partial => "partial",
            Action::Submit => "submit",
            Action::Abandon => "abandon",
        }
    }
}

/// Surveys data-API client.
pub struct Surveys {
    transport: Transport,
    api_key: String,
}

impl Surveys {
    /// Construct the surveys client.
    pub fn new(transport: Transport, api_key: String) -> Self {
        Self { transport, api_key }
    }

    fn auth(&self) -> (String, String) {
        ("x-api-key".to_string(), self.api_key.clone())
    }

    /// List active surveys eligible for the (optionally identified) user.
    pub fn list(
        &self,
        distinct_id: Option<&str>,
        anonymous_id: Option<&str>,
    ) -> Result<Vec<SurveyListItem>> {
        let mut body = Map::new();
        body.insert("api_key".to_string(), json!(self.api_key));
        if let Some(c) = distinct_id {
            body.insert("customer_id".to_string(), json!(c));
        }
        if let Some(a) = anonymous_id {
            body.insert("anonymous_id".to_string(), json!(a));
        }
        let (hk, hv) = self.auth();
        let opts = RequestOptions::post("/bdsurvey-list", Value::Object(body))
            .header(hk, hv)
            .gzip(false);
        let data = self.transport.request(&opts)?;
        let surveys = data
            .get("surveys")
            .and_then(|v| serde_json::from_value(v.clone()).ok())
            .unwrap_or_default();
        Ok(surveys)
    }

    /// Fetch the full configuration for a single survey (raw JSON).
    pub fn fetch(
        &self,
        survey_id: &str,
        distinct_id: Option<&str>,
        anonymous_id: Option<&str>,
    ) -> Result<Value> {
        let mut body = Map::new();
        body.insert("api_key".to_string(), json!(self.api_key));
        body.insert("survey_id".to_string(), json!(survey_id));
        if let Some(c) = distinct_id {
            body.insert("customer_id".to_string(), json!(c));
        }
        if let Some(a) = anonymous_id {
            body.insert("anonymous_id".to_string(), json!(a));
        }
        let (hk, hv) = self.auth();
        let opts = RequestOptions::post("/bdsurvey-fetch", Value::Object(body))
            .header(hk, hv)
            .gzip(false);
        self.transport.request(&opts)
    }

    /// Begin a survey response. Returns the raw JSON containing `respondent_id`.
    pub fn start(&self, survey_id: &str, context: &SurveyContext) -> Result<Value> {
        self.submit_request(Action::Start, survey_id, None, context)
    }

    /// Progressively persist a partial set of answers under an in-progress respondent.
    pub fn record_partial(
        &self,
        survey_id: &str,
        respondent_id: &str,
        answers: &[SurveyAnswer],
        context: &SurveyContext,
    ) -> Result<Value> {
        let mut ctx = context.clone();
        ctx.respondent_id = Some(respondent_id.to_string());
        self.submit_request(Action::Partial, survey_id, Some(answers), &ctx)
    }

    /// Submit the final answers, completing the response.
    pub fn submit(
        &self,
        survey_id: &str,
        answers: &[SurveyAnswer],
        context: &SurveyContext,
    ) -> Result<Value> {
        self.submit_request(Action::Submit, survey_id, Some(answers), context)
    }

    /// Mark an in-progress response as abandoned.
    pub fn abandon(&self, survey_id: &str, respondent_id: &str) -> Result<Value> {
        let ctx = SurveyContext {
            respondent_id: Some(respondent_id.to_string()),
            ..Default::default()
        };
        self.submit_request(Action::Abandon, survey_id, None, &ctx)
    }

    fn submit_request(
        &self,
        action: Action,
        survey_id: &str,
        answers: Option<&[SurveyAnswer]>,
        context: &SurveyContext,
    ) -> Result<Value> {
        let body = self.build_body(action, survey_id, answers, context);
        let (hk, hv) = self.auth();
        let opts = RequestOptions::post("/bdsurvey-submit", body)
            .header(hk, hv)
            .gzip(false);
        self.transport.request(&opts)
    }

    fn build_body(
        &self,
        action: Action,
        survey_id: &str,
        answers: Option<&[SurveyAnswer]>,
        ctx: &SurveyContext,
    ) -> Value {
        let mut body = Map::new();
        body.insert("action".to_string(), json!(action.as_str()));
        body.insert("api_key".to_string(), json!(self.api_key));
        body.insert("survey_id".to_string(), json!(survey_id));
        if let Some(a) = answers {
            body.insert("answers".to_string(), json!(a));
        }
        if let Some(v) = &ctx.respondent_id {
            body.insert("respondent_id".to_string(), json!(v));
        }
        if let Some(v) = &ctx.customer_id {
            body.insert("customer_id".to_string(), json!(v));
        }
        if let Some(v) = &ctx.anonymous_id {
            body.insert("anonymous_id".to_string(), json!(v));
        }
        if let Some(v) = &ctx.session_id {
            body.insert("session_id".to_string(), json!(v));
        }
        if let Some(v) = &ctx.platform {
            body.insert("platform".to_string(), json!(v));
        }
        if let Some(v) = &ctx.device_info {
            body.insert("device_info".to_string(), Value::Object(v.clone()));
        }
        if let Some(v) = ctx.duration_ms {
            body.insert("duration_ms".to_string(), json!(v));
        }
        if let Some(v) = &ctx.context {
            body.insert("context".to_string(), Value::Object(v.clone()));
        }
        if let Some(v) = &ctx.collector_id {
            body.insert("collector_id".to_string(), json!(v));
        }
        if let Some(v) = &ctx.collector_type {
            body.insert("collector_type".to_string(), json!(v));
        }
        if let Some(v) = &ctx.idempotency_key {
            body.insert("idempotency_key".to_string(), json!(v));
        }
        Value::Object(body)
    }
}