Skip to main content

videosdk/resources/
batch_calls.rs

1//! Bulk outbound phone campaigns.
2//!
3//! Lifecycle: [`upload`](BatchCallsResource::upload) a recipients file — it is
4//! uploaded and parsed in one call — then [`create`](BatchCallsResource::create)
5//! to launch or schedule the campaign.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::path::Path;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use futures_util::Stream;
14use reqwest::Method;
15use serde::de::{Deserializer, Error as _};
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Map, Value};
18
19use crate::client::{CallOptions, Client};
20use crate::common::{string_enum, MessageResponse};
21use crate::error::{Error, ErrorKind, Result};
22use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
23use crate::query::QueryBuilder;
24use crate::resources::escape;
25
26const PATH: &str = "/v2/batch-calls";
27
28/// The recipients file may be at most 5 MiB.
29const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
30const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
31const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(120);
32
33/// Resolves the poll cadence and deadline.
34///
35/// A zero interval would spin the loop as fast as the API answers, and a zero
36/// deadline would give up before the first check. Treat either as unset rather
37/// than honouring a value that cannot have been meant.
38fn resolve_poll(interval: Option<Duration>, timeout: Option<Duration>) -> (Duration, Duration) {
39    (
40        interval
41            .filter(|interval| !interval.is_zero())
42            .unwrap_or(DEFAULT_POLL_INTERVAL),
43        timeout
44            .filter(|timeout| !timeout.is_zero())
45            .unwrap_or(DEFAULT_POLL_TIMEOUT),
46    )
47}
48
49/// The content types the upload endpoint will sign.
50fn upload_content_type(extension: &str) -> Option<&'static str> {
51    match extension.to_ascii_lowercase().as_str() {
52        "csv" => Some("text/csv"),
53        "xls" => Some("application/vnd.ms-excel"),
54        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
55        _ => None,
56    }
57}
58
59string_enum! {
60    /// When a batch runs.
61    BatchCallTiming {
62        /// Run as soon as it is created.
63        IMMEDIATE => "immediate",
64        /// Run at the scheduled time.
65        SCHEDULED => "scheduled",
66    }
67}
68
69string_enum! {
70    /// A batch's lifecycle state.
71    BatchCallStatus {
72        /// The recipients file was uploaded.
73        UPLOADED => "uploaded",
74        /// The recipients file is being parsed.
75        PARSING => "parsing",
76        /// The recipients file parsed, and the batch is ready to launch.
77        PARSED => "parsed",
78        /// The recipients file could not be parsed.
79        PARSE_FAILED => "parse_failed",
80        /// The batch is saved as a draft.
81        DRAFT => "draft",
82        /// The batch is scheduled.
83        SCHEDULED => "scheduled",
84        /// The batch is placing calls.
85        RUNNING => "running",
86        /// The batch finished.
87        COMPLETED => "completed",
88        /// The batch is winding down.
89        CANCELLING => "cancelling",
90        /// The batch was cancelled.
91        CANCELLED => "cancelled",
92    }
93}
94
95string_enum! {
96    /// How a batch is cancelled.
97    BatchCallCancelMode {
98        /// Let in-flight calls finish.
99        GRACEFUL => "graceful",
100        /// Drop in-flight calls.
101        IMMEDIATE => "immediate",
102    }
103}
104
105string_enum! {
106    /// A call status that can trigger a retry.
107    BatchCallRetryStatus {
108        /// The call failed.
109        FAILED => "failed",
110        /// The call was not answered.
111        NOT_ANSWERED => "not-answered",
112        /// The call was rejected.
113        REJECTED => "rejected",
114        /// The call was missed.
115        MISSED => "missed",
116        /// The caller hung up first.
117        ABANDONED => "abandoned",
118    }
119}
120
121string_enum! {
122    /// A batch-call webhook event.
123    BatchCallWebhookEvent {
124        /// The batch started.
125        BATCHCALL_STARTED => "batchcall-started",
126        /// The batch completed.
127        BATCHCALL_COMPLETED => "batchcall-completed",
128    }
129}
130
131/// Configures retries for a batch.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct BatchCallRetryConfig {
135    /// Whether to retry.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub enabled: Option<bool>,
138    /// Which call statuses trigger a retry.
139    #[serde(
140        default,
141        deserialize_with = "crate::common::null_to_default",
142        skip_serializing_if = "Vec::is_empty"
143    )]
144    pub retry_on: Vec<BatchCallRetryStatus>,
145    /// The maximum attempts, from 1 to 3.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub max_attempts: Option<u32>,
148}
149
150/// Configures a batch's webhook.
151#[derive(Debug, Clone, Default, Serialize)]
152pub struct BatchCallWebhookConfig {
153    /// Where to deliver events.
154    pub endpoint: String,
155    /// The events to subscribe to.
156    #[serde(skip_serializing_if = "Vec::is_empty")]
157    pub events: Vec<BatchCallWebhookEvent>,
158}
159
160/// A bulk outbound phone campaign.
161#[derive(Debug, Clone, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct BatchCall {
164    /// The batch id.
165    pub batch_id: String,
166    /// The batch's display name.
167    pub batch_name: Option<String>,
168    /// The batch's description.
169    pub description: Option<String>,
170    /// The outbound phone number id.
171    pub phone_number_id: Option<String>,
172    /// The outbound phone number.
173    pub phone_number: Option<String>,
174    /// The outbound routing rule id.
175    pub routing_rule_id: Option<String>,
176    /// When the batch runs.
177    pub timing: Option<BatchCallTiming>,
178    /// The IANA timezone the schedule is interpreted in.
179    pub timezone: Option<String>,
180    /// The scheduled time, as `HH:MM`.
181    pub scheduled_time: Option<String>,
182    /// The scheduled date, as `YYYY-MM-DD`.
183    pub scheduled_date: Option<String>,
184    /// The start of the allowed calling window, as `HH:MM`.
185    pub allowed_from: Option<String>,
186    /// The end of the allowed calling window, as `HH:MM`.
187    pub allowed_until: Option<String>,
188    /// The days the batch may call: 0 is Sunday through 6, Saturday.
189    #[serde(default, deserialize_with = "crate::common::null_to_default")]
190    pub days_of_week: Vec<u32>,
191    /// The batch's retry configuration.
192    pub retry: Option<BatchCallRetryConfig>,
193    /// The batch's status.
194    pub status: Option<BatchCallStatus>,
195    /// Where the recipients file can be downloaded.
196    pub cdn_url: Option<String>,
197    /// How many records were uploaded.
198    pub records_uploaded: Option<u32>,
199    /// Why parsing failed, when it did.
200    pub parse_error: Option<String>,
201    /// The batch's timestamp (scheduled run time, or creation time for
202    /// immediate batches), in epoch seconds.
203    pub timestamp: Option<i64>,
204    /// Completion percentage, from 0 to 100 (list view only).
205    pub progress: Option<f64>,
206    /// When the batch was created.
207    pub created_at: Option<String>,
208    /// When the batch was last updated.
209    pub updated_at: Option<String>,
210    /// Any fields the server returned that this SDK does not model yet.
211    #[serde(flatten)]
212    pub extra: Map<String, Value>,
213}
214
215/// A single per-number record in a batch.
216#[derive(Debug, Clone, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct BatchCallRecord {
219    /// The batch this record belongs to.
220    #[serde(default, deserialize_with = "crate::common::null_to_default")]
221    pub batch_id: String,
222    /// The record id.
223    pub record_id: String,
224    /// The number to call.
225    pub phone_number: Option<String>,
226    /// Free-form metadata, merged into the call.
227    pub metadata: Option<Map<String, Value>>,
228    /// The record's status.
229    pub status: Option<String>,
230    /// The call placed for this record.
231    pub call_id: Option<String>,
232    /// Why the call did not connect.
233    pub reason: Option<String>,
234    /// How many attempts were made.
235    pub attempts: Option<u32>,
236    /// The recording of this record's call.
237    pub recording_id: Option<String>,
238    /// The recording object.
239    pub recording: Option<Value>,
240    /// How long the call lasted, in seconds.
241    pub call_duration: Option<f64>,
242    /// When the record was created.
243    pub created_at: Option<String>,
244    /// When the record was last updated.
245    pub updated_at: Option<String>,
246    /// Any fields the server returned that this SDK does not model yet.
247    #[serde(flatten)]
248    pub extra: Map<String, Value>,
249}
250
251/// A status breakdown for a batch.
252#[derive(Debug, Clone, Default)]
253pub struct BatchCallStats {
254    /// The batch this breakdown describes.
255    pub batch_id: String,
256    /// The batch's status.
257    pub status: Option<BatchCallStatus>,
258    /// The total record count.
259    pub total: Option<i64>,
260    /// The per-status tallies, keyed by display label, e.g. `scheduled`,
261    /// `running`, `completed`, `not connected`, `cancelled` — plus `total`.
262    pub counts: HashMap<String, i64>,
263}
264
265impl<'de> Deserialize<'de> for BatchCallStats {
266    /// The per-status breakdown arrives as arbitrary sibling keys, so every
267    /// remaining numeric field is collected into `counts`.
268    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
269        let raw = Map::deserialize(deserializer)?;
270        let mut stats = BatchCallStats {
271            batch_id: raw
272                .get("batchId")
273                .and_then(Value::as_str)
274                .unwrap_or_default()
275                .to_string(),
276            ..Default::default()
277        };
278
279        if let Some(status) = raw.get("status") {
280            stats.status = Some(serde_json::from_value(status.clone()).map_err(D::Error::custom)?);
281        }
282
283        for (key, value) in &raw {
284            if key == "batchId" || key == "status" {
285                continue;
286            }
287            if let Some(count) = value.as_i64() {
288                stats.counts.insert(key.clone(), count);
289                if key == "total" {
290                    stats.total = Some(count);
291                }
292            }
293        }
294        Ok(stats)
295    }
296}
297
298/// The presigned target the API returns for a recipients file.
299#[derive(Debug, Deserialize)]
300#[serde(rename_all = "camelCase")]
301struct BatchPresignedUpload {
302    presigned_url: String,
303    batch_id: String,
304}
305
306/// The result of a bulk record deletion.
307#[derive(Debug, Clone, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct DeleteRecordsResult {
310    /// The server's confirmation message.
311    pub message: String,
312    /// How many records were deleted.
313    #[serde(default, deserialize_with = "crate::common::null_to_default")]
314    pub deleted_count: u32,
315}
316
317/* --------------------------------- parameters --------------------------------- */
318
319/// The parameters for [`BatchCallsResource::upload`].
320#[derive(Default)]
321pub struct UploadBatchCallParams {
322    /// The path to a `.csv`, `.xls` or `.xlsx` recipients file, at most 5 MiB.
323    /// Required.
324    pub file_path: String,
325    /// Replaces the file on an existing batch, instead of creating a new one.
326    pub batch_id: Option<String>,
327    /// Waits until parsing finishes before returning. Defaults to `true`.
328    ///
329    /// When `false`, upload returns as soon as parsing starts, with the batch
330    /// still in [`BatchCallStatus::PARSING`].
331    pub wait_for_parse: Option<bool>,
332    /// How often to poll while waiting. Defaults to 2 seconds.
333    pub poll_interval: Option<Duration>,
334    /// How long to wait for parsing. Defaults to 2 minutes.
335    pub poll_timeout: Option<Duration>,
336    /// Called once parsing has started. An optional progress hook — the parsed
337    /// batch is the return value of upload.
338    pub on_parsing: Option<Box<dyn FnOnce() + Send>>,
339}
340
341impl fmt::Debug for UploadBatchCallParams {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        f.debug_struct("UploadBatchCallParams")
344            .field("file_path", &self.file_path)
345            .field("batch_id", &self.batch_id)
346            .field("wait_for_parse", &self.wait_for_parse)
347            .field("poll_interval", &self.poll_interval)
348            .field("poll_timeout", &self.poll_timeout)
349            .field("on_parsing", &self.on_parsing.is_some())
350            .finish()
351    }
352}
353
354/// The parameters for [`BatchCallsResource::create`].
355#[derive(Debug, Clone, Default, Serialize)]
356#[serde(rename_all = "camelCase")]
357pub struct CreateBatchCallParams {
358    /// The display name. Required.
359    pub batch_name: String,
360    /// The outbound phone number id. Required.
361    pub phone_number_id: String,
362    /// The outbound routing rule id, attached to the number. Required.
363    pub routing_rule_id: String,
364    /// The batch's description.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub description: Option<String>,
367    /// When the batch runs.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub timing: Option<BatchCallTiming>,
370    /// An IANA timezone. Defaults to `Asia/Calcutta`.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub timezone: Option<String>,
373    /// The scheduled time, as `HH:MM`. Required when scheduled.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub scheduled_time: Option<String>,
376    /// The scheduled date, as `YYYY-MM-DD`. Required when scheduled.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub scheduled_date: Option<String>,
379    /// The start of the allowed calling window, as `HH:MM`. Scheduled batches only.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub allowed_from: Option<String>,
382    /// The end of the allowed calling window, as `HH:MM`.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub allowed_until: Option<String>,
385    /// The days the batch may call: 0 is Sunday through 6, Saturday.
386    #[serde(skip_serializing_if = "Vec::is_empty")]
387    pub days_of_week: Vec<u32>,
388    /// The retry configuration.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub retry: Option<BatchCallRetryConfig>,
391    /// The webhook configuration.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub webhook: Option<BatchCallWebhookConfig>,
394    /// Finalizes a previously uploaded and parsed draft.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub batch_id: Option<String>,
397    /// Saves as a draft instead of launching.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub save_as_draft: Option<bool>,
400}
401
402/// The editable fields of a batch: draft, scheduled or parsed.
403#[derive(Debug, Clone, Default, Serialize)]
404#[serde(rename_all = "camelCase")]
405pub struct UpdateBatchCallParams {
406    /// The display name.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub batch_name: Option<String>,
409    /// The outbound phone number id.
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub phone_number_id: Option<String>,
412    /// The outbound routing rule id.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub routing_rule_id: Option<String>,
415    /// The batch's description.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub description: Option<String>,
418    /// When the batch runs.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub timing: Option<BatchCallTiming>,
421    /// An IANA timezone.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub timezone: Option<String>,
424    /// The scheduled time, as `HH:MM`.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub scheduled_time: Option<String>,
427    /// The scheduled date, as `YYYY-MM-DD`.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub scheduled_date: Option<String>,
430    /// The start of the allowed calling window.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub allowed_from: Option<String>,
433    /// The end of the allowed calling window.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub allowed_until: Option<String>,
436    /// The days the batch may call.
437    #[serde(skip_serializing_if = "Vec::is_empty")]
438    pub days_of_week: Vec<u32>,
439    /// The retry configuration.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub retry: Option<BatchCallRetryConfig>,
442    /// Saves as a draft instead of launching.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub save_as_draft: Option<bool>,
445}
446
447/// The query parameters for [`BatchCallsResource::list`].
448#[derive(Debug, Clone, Default)]
449pub struct ListBatchCallsParams {
450    /// The 1-based page number.
451    pub page: Option<u32>,
452    /// Items per page.
453    pub per_page: Option<u32>,
454    /// An opaque cursor from a previous page.
455    pub cursor: Option<String>,
456    /// Filters by batch status.
457    pub status: Option<BatchCallStatus>,
458    /// An ISO 8601 lower bound.
459    pub start_date: Option<String>,
460    /// An ISO 8601 upper bound.
461    pub end_date: Option<String>,
462    /// The minimum number of uploaded records.
463    pub min_value: Option<u32>,
464    /// The maximum number of uploaded records.
465    pub max_value: Option<u32>,
466}
467
468/// The query parameters for [`BatchCallRecordsResource::list`].
469#[derive(Debug, Clone, Default)]
470pub struct ListBatchCallRecordsParams {
471    /// The 1-based page number.
472    pub page: Option<u32>,
473    /// Items per page.
474    pub per_page: Option<u32>,
475    /// An opaque cursor from a previous page.
476    pub cursor: Option<String>,
477    /// A comma-separated set of display labels, e.g. `scheduled,not connected`.
478    pub status: Option<String>,
479    /// Matches over record id, phone number, status and reason.
480    pub search: Option<String>,
481}
482
483/// The editable fields of a pending record.
484#[derive(Debug, Clone, Default, Serialize)]
485#[serde(rename_all = "camelCase")]
486pub struct UpdateBatchCallRecordParams {
487    /// The number to call.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub phone_number: Option<String>,
490    /// Free-form metadata, merged into the call.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub metadata: Option<Map<String, Value>>,
493}
494
495/// The query parameters for [`BatchCallRecordsResource::export`].
496#[derive(Debug, Clone, Default)]
497pub struct ExportBatchCallRecordsParams {
498    /// A comma-separated set of display labels.
499    pub status: Option<String>,
500    /// Matches over record id, phone number, status and reason.
501    pub search: Option<String>,
502}
503
504macro_rules! pagination {
505    ($name:ident) => {
506        impl $name {
507            fn pagination(&self) -> ListParams {
508                ListParams {
509                    page: self.page,
510                    per_page: self.per_page,
511                    cursor: self.cursor.clone(),
512                }
513            }
514        }
515    };
516}
517
518pagination!(ListBatchCallsParams);
519pagination!(ListBatchCallRecordsParams);
520
521/* --------------------------------- resources ----------------------------------- */
522
523/// Bulk outbound phone campaigns. Reached via [`Client::batch_call`].
524#[derive(Debug, Clone, Copy)]
525pub struct BatchCallsResource<'a> {
526    client: &'a Client,
527}
528
529impl<'a> BatchCallsResource<'a> {
530    pub(crate) fn new(client: &'a Client) -> Self {
531        Self { client }
532    }
533
534    /// A batch's per-number records.
535    pub fn records(&self) -> BatchCallRecordsResource<'a> {
536        BatchCallRecordsResource {
537            client: self.client,
538        }
539    }
540
541    /// Uploads a recipients file, starts parsing, and — unless `wait_for_parse`
542    /// is `false` — waits until the batch is parsed and ready to launch.
543    ///
544    /// Pass the returned batch's `batch_id` to
545    /// [`create`](BatchCallsResource::create).
546    ///
547    /// Under the hood this asks for a presigned URL, `PUT`s the file straight to
548    /// storage without an `Authorization` header — sending one would invalidate
549    /// the presigned signature — then triggers parsing and polls.
550    ///
551    /// Drop the returned future to cancel.
552    pub async fn upload(&self, params: UploadBatchCallParams) -> Result<BatchCall> {
553        let path = Path::new(&params.file_path);
554        let file_name = path
555            .file_name()
556            .map(|name| name.to_string_lossy().into_owned())
557            .ok_or_else(|| {
558                Error::validation(format!(
559                    "invalid batch-call file path {:?}",
560                    params.file_path
561                ))
562            })?;
563
564        let extension = path
565            .extension()
566            .map(|e| e.to_string_lossy())
567            .unwrap_or_default();
568        let content_type = upload_content_type(&extension).ok_or_else(|| {
569            Error::validation(format!(
570                "unsupported batch-call file {file_name:?}; allowed types: .csv, .xls, .xlsx"
571            ))
572        })?;
573
574        let data = tokio::fs::read(&params.file_path).await.map_err(|e| {
575            Error::validation(format!(
576                "could not read batch-call file {:?}: {e}",
577                params.file_path
578            ))
579        })?;
580        if data.len() > MAX_UPLOAD_BYTES {
581            return Err(Error::validation(format!(
582                "batch-call file exceeds the {} MB limit",
583                MAX_UPLOAD_BYTES / 1024 / 1024
584            )));
585        }
586
587        let mut upload_body = Map::new();
588        upload_body.insert("fileName".into(), json!(file_name));
589        upload_body.insert("fileSize".into(), json!(data.len()));
590        if let Some(batch_id) = &params.batch_id {
591            upload_body.insert("batchId".into(), json!(batch_id));
592        }
593
594        let upload_path = format!("{PATH}/upload");
595        let presigned: BatchPresignedUpload = self
596            .client
597            .json(Method::POST, &upload_path, CallOptions::json(&upload_body)?)
598            .await?;
599
600        self.client
601            .put_binary(&presigned.presigned_url, &data, content_type)
602            .await?;
603
604        let parse_path = format!("{PATH}/parse");
605        let parse_body = json!({ "batchId": presigned.batch_id });
606        self.client
607            .none(Method::POST, &parse_path, CallOptions::json(&parse_body)?)
608            .await?;
609
610        if let Some(on_parsing) = params.on_parsing {
611            on_parsing();
612        }
613
614        if params.wait_for_parse == Some(false) {
615            return self.get(&presigned.batch_id).await;
616        }
617        let (interval, timeout) = resolve_poll(params.poll_interval, params.poll_timeout);
618        self.poll_until_parsed(&presigned.batch_id, interval, timeout)
619            .await
620    }
621
622    /// Polls until the batch is parsed, parsing fails, or the timeout elapses.
623    async fn poll_until_parsed(
624        &self,
625        batch_id: &str,
626        interval: Duration,
627        timeout: Duration,
628    ) -> Result<BatchCall> {
629        let deadline = Instant::now() + timeout;
630        loop {
631            let batch = self.get(batch_id).await?;
632            match batch.status.as_ref().map(BatchCallStatus::as_str) {
633                Some("parsed") => return Ok(batch),
634                Some("parse_failed") => {
635                    let mut message = "parsing the batch-call file failed".to_string();
636                    if let Some(parse_error) = &batch.parse_error {
637                        message.push_str(": ");
638                        message.push_str(parse_error);
639                    }
640                    return Err(Error::operation(
641                        ErrorKind::Generic,
642                        "batchcall_parse_failed",
643                        message,
644                    ));
645                }
646                _ => {}
647            }
648
649            if Instant::now() >= deadline {
650                let status = batch
651                    .status
652                    .as_ref()
653                    .map(BatchCallStatus::as_str)
654                    .unwrap_or("unknown");
655                return Err(Error::operation(
656                    ErrorKind::Timeout,
657                    "batchcall_parse_timeout",
658                    format!(
659                        "timed out after {timeout:?} waiting for batch {batch_id:?} to finish \
660                         parsing (last status: {status})"
661                    ),
662                ));
663            }
664            tokio::time::sleep(interval).await;
665        }
666    }
667
668    /// Launches or schedules a batch from a parsed `batch_id`, or saves a draft.
669    pub async fn create(&self, params: CreateBatchCallParams) -> Result<BatchCall> {
670        self.client
671            .json(Method::POST, PATH, CallOptions::json(&params)?)
672            .await
673    }
674
675    /// Lists batches, one page at a time.
676    pub async fn list(&self, params: ListBatchCallsParams) -> Result<Page<BatchCall>> {
677        paginate(
678            self.fetcher(&params),
679            &params.pagination(),
680            "batchCalls",
681            None,
682        )
683        .await
684    }
685
686    /// Lists batches, transparently fetching every page.
687    pub fn list_stream(
688        &self,
689        params: ListBatchCallsParams,
690    ) -> impl Stream<Item = Result<BatchCall>> + Send {
691        auto_page(
692            self.fetcher(&params),
693            params.pagination(),
694            "batchCalls",
695            None,
696        )
697    }
698
699    /// Fetches a batch by id.
700    pub async fn get(&self, batch_id: &str) -> Result<BatchCall> {
701        let path = format!("{PATH}/{}", escape(batch_id));
702        self.client
703            .json(Method::GET, &path, CallOptions::new())
704            .await
705    }
706
707    /// Updates an editable batch: draft, scheduled or parsed.
708    pub async fn update(&self, batch_id: &str, params: UpdateBatchCallParams) -> Result<BatchCall> {
709        let path = format!("{PATH}/{}", escape(batch_id));
710        self.client
711            .json(Method::PUT, &path, CallOptions::json(&params)?)
712            .await
713    }
714
715    /// Soft-deletes a batch and its records.
716    pub async fn delete(&self, batch_id: &str) -> Result<MessageResponse> {
717        let path = format!("{PATH}/{}", escape(batch_id));
718        self.client
719            .json(Method::DELETE, &path, CallOptions::new())
720            .await
721    }
722
723    /// Cancels a running or scheduled batch.
724    pub async fn cancel(&self, batch_id: &str, mode: BatchCallCancelMode) -> Result<BatchCall> {
725        let path = format!("{PATH}/{}/cancel", escape(batch_id));
726        let body = json!({ "mode": mode });
727        self.client
728            .json(Method::POST, &path, CallOptions::json(&body)?)
729            .await
730    }
731
732    /// Returns a status breakdown for a batch.
733    pub async fn stats(&self, batch_id: &str) -> Result<BatchCallStats> {
734        let path = format!("{PATH}/{}/stats", escape(batch_id));
735        self.client
736            .json(Method::GET, &path, CallOptions::new())
737            .await
738    }
739
740    fn fetcher(&self, params: &ListBatchCallsParams) -> PageFetcher {
741        let client = self.client.clone();
742        let params = params.clone();
743        Arc::new(move |page, per_page| {
744            let client = client.clone();
745            let params = params.clone();
746            Box::pin(async move {
747                let query = QueryBuilder::new()
748                    .opt("page", page)
749                    .opt("perPage", per_page)
750                    .opt_str(
751                        "status",
752                        params.status.as_ref().map(BatchCallStatus::as_str),
753                    )
754                    .opt_str("startDate", params.start_date.as_deref())
755                    .opt_str("endDate", params.end_date.as_deref())
756                    .opt("minValue", params.min_value)
757                    .opt("maxValue", params.max_value)
758                    .into_pairs();
759                client
760                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
761                    .await
762            })
763        })
764    }
765}
766
767/// A batch's per-number records. Reached via [`BatchCallsResource::records`].
768#[derive(Debug, Clone, Copy)]
769pub struct BatchCallRecordsResource<'a> {
770    client: &'a Client,
771}
772
773impl<'a> BatchCallRecordsResource<'a> {
774    /// Lists a batch's records, one page at a time.
775    pub async fn list(
776        &self,
777        batch_id: &str,
778        params: ListBatchCallRecordsParams,
779    ) -> Result<Page<BatchCallRecord>> {
780        let fetcher = self.fetcher(batch_id, &params);
781        paginate(fetcher, &params.pagination(), "records", None).await
782    }
783
784    /// Lists a batch's records, transparently fetching every page.
785    pub fn list_stream(
786        &self,
787        batch_id: &str,
788        params: ListBatchCallRecordsParams,
789    ) -> impl Stream<Item = Result<BatchCallRecord>> + Send {
790        let fetcher = self.fetcher(batch_id, &params);
791        auto_page(fetcher, params.pagination(), "records", None)
792    }
793
794    /// Edits a single pending record before it runs.
795    pub async fn update(
796        &self,
797        batch_id: &str,
798        record_id: &str,
799        params: UpdateBatchCallRecordParams,
800    ) -> Result<BatchCallRecord> {
801        let path = format!("{PATH}/{}/records/{}", escape(batch_id), escape(record_id));
802        self.client
803            .json(Method::PUT, &path, CallOptions::json(&params)?)
804            .await
805    }
806
807    /// Bulk soft-deletes records.
808    pub async fn delete(
809        &self,
810        batch_id: &str,
811        record_ids: &[String],
812    ) -> Result<DeleteRecordsResult> {
813        let path = format!("{PATH}/{}/records", escape(batch_id));
814        let body = json!({ "recordIds": record_ids });
815        self.client
816            .json(Method::DELETE, &path, CallOptions::json(&body)?)
817            .await
818    }
819
820    /// Exports a batch's records as CSV text.
821    pub async fn export(
822        &self,
823        batch_id: &str,
824        params: ExportBatchCallRecordsParams,
825    ) -> Result<String> {
826        let path = format!("{PATH}/{}/records/export", escape(batch_id));
827        let query = QueryBuilder::new()
828            .opt_str("status", params.status.as_deref())
829            .opt_str("search", params.search.as_deref())
830            .into_pairs();
831        self.client
832            .text(Method::GET, &path, CallOptions::new().query(query))
833            .await
834    }
835
836    fn fetcher(&self, batch_id: &str, params: &ListBatchCallRecordsParams) -> PageFetcher {
837        let client = self.client.clone();
838        let params = params.clone();
839        let path = format!("{PATH}/{}/records", escape(batch_id));
840        Arc::new(move |page, per_page| {
841            let client = client.clone();
842            let params = params.clone();
843            let path = path.clone();
844            Box::pin(async move {
845                // This endpoint calls its search parameter `query`.
846                let query = QueryBuilder::new()
847                    .opt("page", page)
848                    .opt("perPage", per_page)
849                    .opt_str("status", params.status.as_deref())
850                    .opt_str("query", params.search.as_deref())
851                    .into_pairs();
852                client
853                    .json::<Value>(Method::GET, &path, CallOptions::new().query(query))
854                    .await
855            })
856        })
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[test]
865    fn stats_collect_every_numeric_sibling_into_counts() {
866        let stats: BatchCallStats = serde_json::from_value(json!({
867            "batchId": "b-1",
868            "status": "running",
869            "total": 10,
870            "scheduled": 3,
871            "completed": 5,
872            "not connected": 2,
873            "someString": "ignored",
874        }))
875        .unwrap();
876
877        assert_eq!(stats.batch_id, "b-1");
878        assert_eq!(stats.status.unwrap().as_str(), "running");
879        assert_eq!(stats.total, Some(10));
880        assert_eq!(stats.counts["scheduled"], 3);
881        assert_eq!(stats.counts["not connected"], 2);
882        assert_eq!(stats.counts["total"], 10, "total is also a count");
883        assert!(!stats.counts.contains_key("someString"));
884        assert!(!stats.counts.contains_key("status"));
885    }
886
887    #[test]
888    fn a_zero_poll_interval_or_timeout_falls_back_to_the_default() {
889        // Honouring a zero interval would busy-loop the API.
890        let (interval, timeout) = resolve_poll(Some(Duration::ZERO), Some(Duration::ZERO));
891        assert_eq!(interval, DEFAULT_POLL_INTERVAL);
892        assert_eq!(timeout, DEFAULT_POLL_TIMEOUT);
893
894        let (interval, timeout) = resolve_poll(None, None);
895        assert_eq!(interval, DEFAULT_POLL_INTERVAL);
896        assert_eq!(timeout, DEFAULT_POLL_TIMEOUT);
897
898        // A real value is honoured, however small.
899        let (interval, timeout) = resolve_poll(
900            Some(Duration::from_millis(5)),
901            Some(Duration::from_millis(50)),
902        );
903        assert_eq!(interval, Duration::from_millis(5));
904        assert_eq!(timeout, Duration::from_millis(50));
905    }
906
907    #[test]
908    fn upload_content_types_cover_the_allowed_extensions() {
909        assert_eq!(upload_content_type("csv"), Some("text/csv"));
910        assert_eq!(upload_content_type("CSV"), Some("text/csv"));
911        assert_eq!(upload_content_type("xls"), Some("application/vnd.ms-excel"));
912        assert!(upload_content_type("xlsx").is_some());
913        assert_eq!(upload_content_type("txt"), None);
914        assert_eq!(upload_content_type(""), None);
915    }
916}