Skip to main content

wavekat_platform_client/
voice.rs

1//! Voice-product resources synced from the desktop daemon up to the
2//! platform.
3//!
4//! The first shipped marker is [`VoiceCalls`] — per-call metadata for
5//! the platform's `/voice/calls` history page (see
6//! `wavekat-voice/docs/21-platform-call-history-sync.md`). Recordings
7//! (`VoiceRecordings`), transcripts (`VoiceTranscripts`), and summaries
8//! will follow the same shape: a marker type, a wire-record struct, and
9//! a typed query — no new HTTP plumbing.
10//!
11//! All wire shapes use camelCase JSON to match the platform's Hono/Zod
12//! convention. The Rust types stay snake_case so consumers feel native.
13
14use serde::{Deserialize, Serialize};
15
16use crate::sync::{HasSyncEnvelope, SyncEndpoint, SyncEnvelope};
17
18/// Inbound vs. outbound. Wire-stable snake_case strings — never
19/// renumber or rename. New states (e.g. `internal`) would be a wire
20/// addition, not a replacement.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum VoiceCallDirection {
24    Inbound,
25    Outbound,
26}
27
28/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the
29/// daemon; the platform stores both, so future UI surfaces can read
30/// either without re-deriving.
31#[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/// Finer-grained terminal reason — kept distinct from
42/// [`VoiceCallDisposition`] because the disposition collapses
43/// `hangup_local` and `hangup_remote` to `Answered`, losing the
44/// "who hung up?" answer the row otherwise carries.
45///
46/// Wire-stable snake_case strings; the daemon's matching enum is the
47/// canonical source.
48#[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/// One historical call as it crosses the wire from the daemon up to the
61/// platform.
62///
63/// Mirrors the daemon's local `CallRecord` (see
64/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
65/// the daemon's local primary key (`id`) is shipped as `source_id`
66/// because the platform allocates its own row id and treats the
67/// daemon-side UUID as the idempotency key.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct VoiceCallRecord {
71    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
72    /// upsert key — re-syncing the same id is a no-op.
73    pub source_id: String,
74    /// SIP account UUID on the daemon side. Opaque to the platform.
75    pub account_id: String,
76    pub direction: VoiceCallDirection,
77    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
78    /// IDs, display names, and SIP URIs all land here.
79    pub party: String,
80    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
81    pub ring_at: String,
82    /// RFC 3339. Present only when the call reached the answered
83    /// state.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub answer_at: Option<String>,
86    /// RFC 3339. Terminal timestamp; the platform uses this as the
87    /// list cursor.
88    pub end_at: String,
89    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
90    /// were never answered.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub duration_ms: Option<i64>,
93    pub disposition: VoiceCallDisposition,
94    pub end_reason: VoiceCallEndReason,
95    /// Free-text error, populated only when `disposition == Failed`.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub error: Option<String>,
98    /// Version + forward-compat fields shared by every sync record.
99    /// Flattened so `schemaVersion` and `extras` sit at the top of
100    /// the JSON object alongside the other columns. See
101    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
102    /// compatibility".
103    #[serde(flatten, default)]
104    pub envelope: SyncEnvelope,
105}
106
107/// Query params for `GET /api/voice/calls`. All fields optional — the
108/// default returns the newest page.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct VoiceCallsQuery {
112    /// RFC 3339 cursor; rows with `end_at < before` are returned.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub before: Option<String>,
115    /// 1..=200. Server default is 50.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub limit: Option<u32>,
118}
119
120/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
121///
122/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
123pub 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        // Optional `error` is None — should be omitted from the wire.
164        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
165        // Envelope flattens to the top of the object — schemaVersion
166        // sits next to the other fields rather than nested under
167        // "envelope". Future resources rely on this layout.
168        assert!(
169            s.contains("\"schemaVersion\":1"),
170            "schemaVersion should flatten: {s}"
171        );
172        // `extras` is None, so the envelope contributes no `extras`
173        // key. Stays out of the row to keep the small/fast path.
174        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
175    }
176
177    #[test]
178    fn record_round_trips_optional_fields() {
179        // An unanswered call has answer_at/duration_ms/error all absent.
180        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        // Empty object — every field skipped when None.
203        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        // The wire form for each direction/disposition/reason must
212        // match what the daemon and platform expect — this guards
213        // against accidental Rust-side renames.
214        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        // A newer client shipping a `notes` field that this platform
253        // version doesn't have a column for should round-trip via
254        // the `extras` envelope. The platform persists the blob
255        // verbatim; a future deploy can promote it to a typed
256        // column without data loss.
257        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}