1use serde::{Deserialize, Serialize};
15
16use crate::sync::{HasSyncEnvelope, SyncEndpoint, SyncEnvelope};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum VoiceCallDirection {
24 Inbound,
25 Outbound,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum VoiceCallDisposition {
34 Answered,
35 Missed,
36 Rejected,
37 Cancelled,
38 Failed,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum VoiceCallEndReason {
51 HangupLocal,
52 HangupRemote,
53 RejectedLocal,
54 RejectedRemote,
55 Missed,
56 CancelledLocal,
57 Failed,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct VoiceCallRecord {
71 pub source_id: String,
74 pub account_id: String,
76 pub direction: VoiceCallDirection,
77 pub party: String,
80 pub ring_at: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub answer_at: Option<String>,
86 pub end_at: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub duration_ms: Option<i64>,
93 pub disposition: VoiceCallDisposition,
94 pub end_reason: VoiceCallEndReason,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub error: Option<String>,
98 #[serde(flatten, default)]
104 pub envelope: SyncEnvelope,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct VoiceCallsQuery {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub before: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub limit: Option<u32>,
118}
119
120pub struct VoiceCalls;
124
125impl SyncEndpoint for VoiceCalls {
126 const RESOURCE: &'static str = "calls";
127 type Record = VoiceCallRecord;
128 type Query = VoiceCallsQuery;
129}
130
131impl HasSyncEnvelope for VoiceCallRecord {
132 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
133 &mut self.envelope
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn record_serializes_with_camel_case_keys() {
143 let r = VoiceCallRecord {
144 source_id: "11111111-1111-4111-8111-111111111111".into(),
145 account_id: "22222222-2222-4222-8222-222222222222".into(),
146 direction: VoiceCallDirection::Inbound,
147 party: "+14155550123".into(),
148 ring_at: "2026-05-16T10:00:00Z".into(),
149 answer_at: Some("2026-05-16T10:00:05Z".into()),
150 end_at: "2026-05-16T10:01:00Z".into(),
151 duration_ms: Some(55_000),
152 disposition: VoiceCallDisposition::Answered,
153 end_reason: VoiceCallEndReason::HangupRemote,
154 error: None,
155 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
156 };
157 let s = serde_json::to_string(&r).unwrap();
158 assert!(s.contains("\"sourceId\":"), "{s}");
159 assert!(s.contains("\"accountId\":"), "{s}");
160 assert!(s.contains("\"ringAt\":"), "{s}");
161 assert!(s.contains("\"endAt\":"), "{s}");
162 assert!(s.contains("\"durationMs\":55000"), "{s}");
163 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
165 assert!(
169 s.contains("\"schemaVersion\":1"),
170 "schemaVersion should flatten: {s}"
171 );
172 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
175 }
176
177 #[test]
178 fn record_round_trips_optional_fields() {
179 let raw = r#"{
181 "sourceId": "a",
182 "accountId": "b",
183 "direction": "inbound",
184 "party": "anonymous",
185 "ringAt": "2026-05-16T10:00:00Z",
186 "endAt": "2026-05-16T10:00:30Z",
187 "disposition": "missed",
188 "endReason": "missed"
189 }"#;
190 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
191 assert!(parsed.answer_at.is_none());
192 assert!(parsed.duration_ms.is_none());
193 assert!(parsed.error.is_none());
194 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
195 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
196 }
197
198 #[test]
199 fn query_omits_unset_fields() {
200 let q = VoiceCallsQuery::default();
201 let s = serde_json::to_string(&q).unwrap();
202 assert_eq!(
204 s, "{}",
205 "default query should serialize to empty object: {s}"
206 );
207 }
208
209 #[test]
210 fn enum_round_trip_via_json() {
211 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
215 let s = serde_json::to_string(&d).unwrap();
216 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
217 assert_eq!(d, back);
218 }
219 for d in [
220 VoiceCallDisposition::Answered,
221 VoiceCallDisposition::Missed,
222 VoiceCallDisposition::Rejected,
223 VoiceCallDisposition::Cancelled,
224 VoiceCallDisposition::Failed,
225 ] {
226 let s = serde_json::to_string(&d).unwrap();
227 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
228 assert_eq!(d, back);
229 }
230 for r in [
231 VoiceCallEndReason::HangupLocal,
232 VoiceCallEndReason::HangupRemote,
233 VoiceCallEndReason::RejectedLocal,
234 VoiceCallEndReason::RejectedRemote,
235 VoiceCallEndReason::Missed,
236 VoiceCallEndReason::CancelledLocal,
237 VoiceCallEndReason::Failed,
238 ] {
239 let s = serde_json::to_string(&r).unwrap();
240 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
241 assert_eq!(r, back);
242 }
243 }
244
245 #[test]
246 fn voice_calls_marker_resource_is_calls() {
247 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
248 }
249
250 #[test]
251 fn record_accepts_unknown_extras_for_forward_compat() {
252 let raw = r#"{
258 "sourceId": "a",
259 "accountId": "b",
260 "direction": "inbound",
261 "party": "anon",
262 "ringAt": "2026-05-16T10:00:00Z",
263 "endAt": "2026-05-16T10:00:30Z",
264 "disposition": "answered",
265 "endReason": "hangup_remote",
266 "schemaVersion": 2,
267 "extras": { "notes": "from staging build" }
268 }"#;
269 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
270 assert_eq!(parsed.envelope.schema_version, Some(2));
271 let extras = parsed.envelope.extras.as_ref().expect("extras present");
272 assert_eq!(extras["notes"], "from staging build");
273 }
274}