use serde::{Deserialize, Serialize};
use crate::sync::{HasSyncEnvelope, SyncEndpoint, SyncEnvelope};
#[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
}
}
#[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");
}
}