use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::{Error, Result};
use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallDirection {
Inbound,
Outbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallDisposition {
Answered,
Missed,
Rejected,
Cancelled,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallEndReason {
HangupLocal,
HangupRemote,
RejectedLocal,
RejectedRemote,
Missed,
CancelledLocal,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceCallRecord {
pub source_id: String,
pub account_id: String,
pub direction: VoiceCallDirection,
pub party: String,
pub ring_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer_at: Option<String>,
pub end_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
pub disposition: VoiceCallDisposition,
pub end_reason: VoiceCallEndReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceCallsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
pub struct VoiceCalls;
impl SyncEndpoint for VoiceCalls {
const RESOURCE: &'static str = "calls";
type Record = VoiceCallRecord;
type Query = VoiceCallsQuery;
}
impl HasSyncEnvelope for VoiceCallRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingRecord {
pub source_id: String,
pub call_source_id: String,
pub size_bytes: u64,
pub duration_ms: u64,
pub sample_rate: u32,
pub channels: u16,
pub created_at: String,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
pub struct VoiceRecordings;
impl SyncEndpoint for VoiceRecordings {
const RESOURCE: &'static str = "recordings";
type Record = VoiceRecordingRecord;
type Query = VoiceRecordingsQuery;
}
impl HasSyncEnvelope for VoiceRecordingRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingSyncItem {
pub source_id: String,
pub r2_key: String,
pub bytes_uploaded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingsSyncResponse {
pub accepted: u32,
pub skipped: u32,
pub items: Vec<VoiceRecordingSyncItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceTranscriptChannel {
Local,
Remote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceTranscriptRecord {
pub source_id: String,
pub call_source_id: String,
pub channel: VoiceTranscriptChannel,
pub ts_ms: i64,
pub end_ms: i64,
pub text: String,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceTranscriptsQuery {
pub call_source_id: String,
}
pub struct VoiceTranscripts;
impl SyncEndpoint for VoiceTranscripts {
const RESOURCE: &'static str = "transcripts";
type Record = VoiceTranscriptRecord;
type Query = VoiceTranscriptsQuery;
}
impl HasSyncEnvelope for VoiceTranscriptRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
impl Client {
pub async fn sync_recordings(
&self,
items: &[VoiceRecordingRecord],
) -> Result<VoiceRecordingsSyncResponse> {
let stamped = stamp_schema_version::<VoiceRecordings>(items);
let body = SyncRequest { items: stamped };
self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
.await
}
pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
if source_id.is_empty() {
return Err(Error::BadRequest("source_id must not be empty".into()));
}
let path = format!("/api/voice/recordings/{source_id}/bytes");
self.put_raw_bytes(&path, "audio/wav", bytes).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_serializes_with_camel_case_keys() {
let r = VoiceCallRecord {
source_id: "11111111-1111-4111-8111-111111111111".into(),
account_id: "22222222-2222-4222-8222-222222222222".into(),
direction: VoiceCallDirection::Inbound,
party: "+14155550123".into(),
ring_at: "2026-05-16T10:00:00Z".into(),
answer_at: Some("2026-05-16T10:00:05Z".into()),
end_at: "2026-05-16T10:01:00Z".into(),
duration_ms: Some(55_000),
disposition: VoiceCallDisposition::Answered,
end_reason: VoiceCallEndReason::HangupRemote,
error: None,
envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"accountId\":"), "{s}");
assert!(s.contains("\"ringAt\":"), "{s}");
assert!(s.contains("\"endAt\":"), "{s}");
assert!(s.contains("\"durationMs\":55000"), "{s}");
assert!(!s.contains("\"error\""), "error should be omitted: {s}");
assert!(
s.contains("\"schemaVersion\":1"),
"schemaVersion should flatten: {s}"
);
assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
}
#[test]
fn record_round_trips_optional_fields() {
let raw = r#"{
"sourceId": "a",
"accountId": "b",
"direction": "inbound",
"party": "anonymous",
"ringAt": "2026-05-16T10:00:00Z",
"endAt": "2026-05-16T10:00:30Z",
"disposition": "missed",
"endReason": "missed"
}"#;
let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
assert!(parsed.answer_at.is_none());
assert!(parsed.duration_ms.is_none());
assert!(parsed.error.is_none());
assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
}
#[test]
fn query_omits_unset_fields() {
let q = VoiceCallsQuery::default();
let s = serde_json::to_string(&q).unwrap();
assert_eq!(
s, "{}",
"default query should serialize to empty object: {s}"
);
}
#[test]
fn enum_round_trip_via_json() {
for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
let s = serde_json::to_string(&d).unwrap();
let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
for d in [
VoiceCallDisposition::Answered,
VoiceCallDisposition::Missed,
VoiceCallDisposition::Rejected,
VoiceCallDisposition::Cancelled,
VoiceCallDisposition::Failed,
] {
let s = serde_json::to_string(&d).unwrap();
let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
for r in [
VoiceCallEndReason::HangupLocal,
VoiceCallEndReason::HangupRemote,
VoiceCallEndReason::RejectedLocal,
VoiceCallEndReason::RejectedRemote,
VoiceCallEndReason::Missed,
VoiceCallEndReason::CancelledLocal,
VoiceCallEndReason::Failed,
] {
let s = serde_json::to_string(&r).unwrap();
let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
assert_eq!(r, back);
}
}
#[test]
fn voice_calls_marker_resource_is_calls() {
assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
}
#[test]
fn record_accepts_unknown_extras_for_forward_compat() {
let raw = r#"{
"sourceId": "a",
"accountId": "b",
"direction": "inbound",
"party": "anon",
"ringAt": "2026-05-16T10:00:00Z",
"endAt": "2026-05-16T10:00:30Z",
"disposition": "answered",
"endReason": "hangup_remote",
"schemaVersion": 2,
"extras": { "notes": "from staging build" }
}"#;
let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.envelope.schema_version, Some(2));
let extras = parsed.envelope.extras.as_ref().expect("extras present");
assert_eq!(extras["notes"], "from staging build");
}
#[test]
fn recording_marker_resource_is_recordings() {
assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
}
#[test]
fn recording_record_serializes_with_camel_case_and_envelope() {
let r = VoiceRecordingRecord {
source_id: "11111111-1111-4111-8111-111111111111".into(),
call_source_id: "22222222-2222-4222-8222-222222222222".into(),
size_bytes: 44 + 64_000,
duration_ms: 2_000,
sample_rate: 8_000,
channels: 2,
created_at: "2026-05-16T10:01:05Z".into(),
envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"callSourceId\":"), "{s}");
assert!(s.contains("\"sizeBytes\":64044"), "{s}");
assert!(s.contains("\"durationMs\":2000"), "{s}");
assert!(s.contains("\"sampleRate\":8000"), "{s}");
assert!(s.contains("\"channels\":2"), "{s}");
assert!(s.contains("\"createdAt\":"), "{s}");
assert!(s.contains("\"schemaVersion\":1"), "{s}");
}
#[test]
fn recordings_sync_response_round_trips() {
let raw = r#"{
"accepted": 2,
"skipped": 0,
"items": [
{"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
{"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
]
}"#;
let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.accepted, 2);
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
assert!(!parsed.items[0].bytes_uploaded);
assert!(parsed.items[1].bytes_uploaded);
}
#[test]
fn transcripts_marker_resource_is_transcripts() {
assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
}
#[test]
fn transcript_record_serializes_with_camel_case_and_channel_enum() {
let r = VoiceTranscriptRecord {
source_id: "1".into(),
call_source_id: "22222222-2222-4222-8222-222222222222".into(),
channel: VoiceTranscriptChannel::Remote,
ts_ms: 100,
end_ms: 1_500,
text: "hello".into(),
envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"callSourceId\":"), "{s}");
assert!(s.contains("\"channel\":\"remote\""), "{s}");
assert!(s.contains("\"tsMs\":100"), "{s}");
assert!(s.contains("\"endMs\":1500"), "{s}");
assert!(s.contains("\"text\":\"hello\""), "{s}");
assert!(s.contains("\"schemaVersion\":1"), "{s}");
}
}