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::client::Client;
17use crate::error::{Error, Result};
18use crate::sign::ReleaseCredential;
19use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
20
21/// Inbound vs. outbound. Wire-stable snake_case strings — never
22/// renumber or rename. New states (e.g. `internal`) would be a wire
23/// addition, not a replacement.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum VoiceCallDirection {
27 Inbound,
28 Outbound,
29}
30
31/// User-visible disposition. Derived from [`VoiceCallEndReason`] by the
32/// daemon; the platform stores both, so future UI surfaces can read
33/// either without re-deriving.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VoiceCallDisposition {
37 Answered,
38 Missed,
39 Rejected,
40 Cancelled,
41 Failed,
42}
43
44/// Finer-grained terminal reason — kept distinct from
45/// [`VoiceCallDisposition`] because the disposition collapses
46/// `hangup_local` and `hangup_remote` to `Answered`, losing the
47/// "who hung up?" answer the row otherwise carries.
48///
49/// Wire-stable snake_case strings; the daemon's matching enum is the
50/// canonical source.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VoiceCallEndReason {
54 HangupLocal,
55 HangupRemote,
56 RejectedLocal,
57 RejectedRemote,
58 Missed,
59 CancelledLocal,
60 /// An established call torn down because its connection died —
61 /// the daemon's RFC 4028 session keepalive stopped getting
62 /// answers (peer crashed, NAT binding dropped). Distinct from
63 /// `HangupLocal`: the user didn't end this call. Rows with this
64 /// reason still carry [`VoiceCallDisposition::Answered`].
65 ConnectionLost,
66 Failed,
67}
68
69/// One historical call as it crosses the wire from the daemon up to the
70/// platform.
71///
72/// Mirrors the daemon's local `CallRecord` (see
73/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
74/// the daemon's local primary key (`id`) is shipped as `source_id`
75/// because the platform allocates its own row id and treats the
76/// daemon-side UUID as the idempotency key.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct VoiceCallRecord {
80 /// Daemon-generated UUID. The platform's `(user_id, source_id)`
81 /// upsert key — re-syncing the same id is a no-op.
82 pub source_id: String,
83 /// SIP account UUID on the daemon side. Opaque to the platform.
84 pub account_id: String,
85 pub direction: VoiceCallDirection,
86 /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
87 /// IDs, display names, and SIP URIs all land here.
88 pub party: String,
89 /// RFC 3339. First ring (inbound) or first dial-out (outbound).
90 pub ring_at: String,
91 /// RFC 3339. Present only when the call reached the answered
92 /// state.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub answer_at: Option<String>,
95 /// RFC 3339. Terminal timestamp; the platform uses this as the
96 /// list cursor.
97 pub end_at: String,
98 /// `answer_at` → `end_at` in milliseconds. `None` for calls that
99 /// were never answered.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub duration_ms: Option<i64>,
102 pub disposition: VoiceCallDisposition,
103 pub end_reason: VoiceCallEndReason,
104 /// Free-text error, populated only when `disposition == Failed`.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub error: Option<String>,
107 /// Version + forward-compat fields shared by every sync record.
108 /// Flattened so `schemaVersion` and `extras` sit at the top of
109 /// the JSON object alongside the other columns. See
110 /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
111 /// compatibility".
112 #[serde(flatten, default)]
113 pub envelope: SyncEnvelope,
114}
115
116/// Query params for `GET /api/voice/calls`. All fields optional — the
117/// default returns the newest page.
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct VoiceCallsQuery {
121 /// RFC 3339 cursor; rows with `end_at < before` are returned.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub before: Option<String>,
124 /// 1..=200. Server default is 50.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub limit: Option<u32>,
127}
128
129/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
130///
131/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
132pub struct VoiceCalls;
133
134impl SyncEndpoint for VoiceCalls {
135 const RESOURCE: &'static str = "calls";
136 type Record = VoiceCallRecord;
137 type Query = VoiceCallsQuery;
138}
139
140impl HasSyncEnvelope for VoiceCallRecord {
141 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
142 &mut self.envelope
143 }
144}
145
146// ---- VoiceRecordings ------------------------------------------------------
147
148/// One per-call recording's metadata as it crosses the wire from the
149/// daemon up to the platform. The WAV bytes ride on a separate
150/// follow-up call ([`Client::upload_recording_bytes`]) so the
151/// idempotent metadata sync stays small and a flaky bytes upload
152/// doesn't force the daemon to re-ship the row.
153///
154/// Mirrors the daemon's `RecordingArtifact` (see
155/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
156/// rename: the daemon's local id (`id`) ships as `source_id` because
157/// the platform allocates its own row id and treats the daemon-side
158/// UUID as the idempotency key (same convention as
159/// [`VoiceCallRecord`]).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct VoiceRecordingRecord {
163 /// Daemon-generated UUID for this recording artifact. Upsert key
164 /// on the platform side.
165 pub source_id: String,
166 /// Daemon's `calls.id` — the call this recording belongs to.
167 /// The platform stores both so the /voice/calls history page can
168 /// link a call to its recording without a separate join table.
169 pub call_source_id: String,
170 /// Byte length of the WAV file the daemon will PUT in the follow-
171 /// up bytes call. The platform refuses a PUT whose body length
172 /// disagrees.
173 pub size_bytes: u64,
174 pub duration_ms: u64,
175 pub sample_rate: u32,
176 pub channels: u16,
177 /// RFC 3339 timestamp the daemon stamped on the artifact at
178 /// finalize time. Drives the platform's `/voice/recordings` GET
179 /// cursor.
180 pub created_at: String,
181 #[serde(flatten, default)]
182 pub envelope: SyncEnvelope,
183}
184
185/// Query params for `GET /api/voice/recordings`.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct VoiceRecordingsQuery {
189 /// RFC 3339 cursor; rows with `created_at < before` are returned.
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub before: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub limit: Option<u32>,
194}
195
196/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
197///
198/// The corresponding bytes-upload endpoint
199/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
200/// [`Client::upload_recording_bytes`] — it doesn't fit the
201/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
202/// inherent method on `Client`.
203pub struct VoiceRecordings;
204
205impl SyncEndpoint for VoiceRecordings {
206 const RESOURCE: &'static str = "recordings";
207 type Record = VoiceRecordingRecord;
208 type Query = VoiceRecordingsQuery;
209}
210
211impl HasSyncEnvelope for VoiceRecordingRecord {
212 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
213 &mut self.envelope
214 }
215}
216
217/// One item in the platform's response to
218/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
219/// key the platform stamped (so a subsequent bytes PUT can target it)
220/// without re-deriving it, and check whether bytes have already
221/// landed on a prior cycle (so the daemon can mark the local row
222/// synced without re-uploading the WAV).
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct VoiceRecordingSyncItem {
226 pub source_id: String,
227 pub r2_key: String,
228 pub bytes_uploaded: bool,
229}
230
231/// Full response from `POST /api/voice/recordings/sync`. Superset of
232/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct VoiceRecordingsSyncResponse {
236 pub accepted: u32,
237 pub skipped: u32,
238 pub items: Vec<VoiceRecordingSyncItem>,
239}
240
241// ---- VoiceTranscripts -----------------------------------------------------
242
243/// Wire-stable transcript channel tag. Matches the daemon's
244/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum VoiceTranscriptChannel {
248 /// Local mic audio — what the user said.
249 Local,
250 /// Received RTP audio — what the remote party said.
251 Remote,
252}
253
254/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
255/// crosses the wire. Each segment is a row on the daemon side
256/// (`transcripts` table); the daemon batches a slice of them per
257/// upload and the platform upserts per (user_id, source_id).
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct VoiceTranscriptRecord {
261 /// Daemon-side row id, formatted as text (the column is an
262 /// autoincrement integer on SQLite). Stable per (call, segment)
263 /// so re-shipping converges.
264 pub source_id: String,
265 /// Daemon's `calls.id` — the call this segment belongs to.
266 pub call_source_id: String,
267 pub channel: VoiceTranscriptChannel,
268 /// Start of the segment in milliseconds relative to the start of
269 /// the call's audio stream (not wall-clock).
270 pub ts_ms: i64,
271 /// End of the segment, same reference frame as `ts_ms`.
272 pub end_ms: i64,
273 /// Recognised text. Free-form; the platform stores it verbatim.
274 pub text: String,
275 #[serde(flatten, default)]
276 pub envelope: SyncEnvelope,
277}
278
279/// Query params for `GET /api/voice/transcripts` — required
280/// `call_source_id` (the endpoint refuses a flat list).
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase")]
283pub struct VoiceTranscriptsQuery {
284 pub call_source_id: String,
285}
286
287/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
288pub struct VoiceTranscripts;
289
290impl SyncEndpoint for VoiceTranscripts {
291 const RESOURCE: &'static str = "transcripts";
292 type Record = VoiceTranscriptRecord;
293 type Query = VoiceTranscriptsQuery;
294}
295
296impl HasSyncEnvelope for VoiceTranscriptRecord {
297 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
298 &mut self.envelope
299 }
300}
301
302// ---- VoiceAccounts --------------------------------------------------------
303
304/// SIP transport for a synced account line. Wire-stable snake_case;
305/// mirrors the daemon's `TransportKind`.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum VoiceTransport {
309 Udp,
310 Tcp,
311}
312
313/// One SIP account line's *configuration* as it crosses the wire from a
314/// device up to the platform and back down to another device
315/// (`wavekat-voice/docs/40-account-config-sync.md`).
316///
317/// Unlike calls / recordings / transcripts — which are immutable,
318/// one-way pushes — account config is **mutable and bidirectional**: a
319/// line is edited, toggled, renamed, and deleted, and those changes must
320/// restore onto a second device. The same idempotent
321/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
322/// every kind of change here; a *delete* is a soft-delete that rides as
323/// an upsert with `deleted_at` set, because a hard DELETE can't sync
324/// under a "push the row" model — once the row is gone there's nothing
325/// left to push.
326///
327/// **No secret field, by construction.** The SIP password never appears
328/// on this wire. Config sync (policy levels 1–2) keeps the credential
329/// device-local, and the end-to-end-encrypted secret path (level 3)
330/// ships its ciphertext through a *separate* opaque resource, never as a
331/// field here. Omitting it means level 3 can't be populated by accident
332/// before it exists.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct VoiceAccountRecord {
336 /// Daemon-side account UUID (`accounts.id`). The platform's
337 /// `(user_id, source_id)` upsert key — re-syncing the same id
338 /// updates the row in place (mutable), unlike the immutable
339 /// resources where a re-sync is a no-op.
340 pub source_id: String,
341 /// Whether the line registers on daemon boot. Pausing a line is a
342 /// portable preference, so it rides along.
343 pub enabled: bool,
344 pub display_name: String,
345 pub username: String,
346 pub domain: String,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub auth_username: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub server: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub port: Option<u16>,
353 pub transport: VoiceTransport,
354 pub register_expires: u32,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub keepalive_secs: Option<u32>,
357 /// Record-disclosure beep toggle — a column on the account row, so
358 /// it rides along for free (the account-portable taxonomy in doc 40).
359 pub disclosure_enabled: bool,
360 /// RFC 3339 last-modification time — the **last-write-wins key**. On
361 /// conflict the platform (and a pulling client) keep the copy with
362 /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
363 /// deferred until users actually report lost edits (doc 40).
364 pub updated_at: String,
365 /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
366 /// was deleted on some device at that time. A tombstone syncs like
367 /// any other mutation so the delete propagates to other devices,
368 /// then is reaped locally once confirmed. The platform retains
369 /// tombstones so a late-syncing device still learns about the delete.
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub deleted_at: Option<String>,
372 /// Version + forward-compat fields shared by every sync record.
373 #[serde(flatten, default)]
374 pub envelope: SyncEnvelope,
375}
376
377/// Query params for `GET /api/voice/accounts`. All fields optional.
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct VoiceAccountsQuery {
381 /// Include soft-deleted tombstones in the response. Absent / false
382 /// returns only live lines — the restore-grade pull a fresh device
383 /// wants. A delta-syncing device sets this `true` to also learn
384 /// about deletes made elsewhere (doc 40).
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub include_deleted: Option<bool>,
387}
388
389/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
390///
391/// Accounts are the first *mutable, bidirectional* sync resource, but
392/// the wire shape is the same idempotent upsert the immutable resources
393/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
394/// was reserved for exactly this case — so no new HTTP plumbing is
395/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
396/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
397pub struct VoiceAccounts;
398
399impl SyncEndpoint for VoiceAccounts {
400 const RESOURCE: &'static str = "accounts";
401 type Record = VoiceAccountRecord;
402 type Query = VoiceAccountsQuery;
403}
404
405impl HasSyncEnvelope for VoiceAccountRecord {
406 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
407 &mut self.envelope
408 }
409}
410
411// ---- Anonymous install heartbeat ------------------------------------------
412//
413// A first-run / per-launch ping the desktop daemon fires *before* (and
414// independently of) any platform sign-in, so the platform can count
415// installs and track version / OS adoption for users who never sign in.
416// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
417// and upserts a row keyed by `install_id` alone (no user) — distinct
418// from the authenticated `voice_clients` heartbeat, which is keyed by
419// `(user, install_id)`.
420//
421// The environment fields (os / os_version / arch / locale) are gathered
422// *here*, inside the client crate, rather than on the consumer side:
423// the daemon only owns the two values this crate genuinely cannot
424// discover — the persisted `install_id` and its own app version.
425
426/// Best-effort snapshot of the host environment, detected at call time.
427/// Every field is best-effort; a probe that fails contributes `None`
428/// (or, for the always-available `os` / `arch`, the compile-time
429/// target) rather than failing the heartbeat.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct SystemInfo {
432 /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
433 pub os: String,
434 /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
435 /// can't determine it.
436 pub os_version: Option<String>,
437 /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
438 pub arch: String,
439 /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
440 /// undetectable (common for GUI-launched apps on some platforms).
441 pub locale: Option<String>,
442}
443
444impl SystemInfo {
445 /// Probe the current host. Cheap enough to call per heartbeat; we
446 /// don't cache so a locale change between launches is reflected.
447 pub fn detect() -> Self {
448 let os_version = match os_info::get().version() {
449 os_info::Version::Unknown => None,
450 v => Some(v.to_string()),
451 };
452 SystemInfo {
453 os: std::env::consts::OS.to_string(),
454 os_version,
455 arch: std::env::consts::ARCH.to_string(),
456 locale: sys_locale::get_locale(),
457 }
458 }
459}
460
461/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
462/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
463/// environment fields from [`SystemInfo::detect`].
464#[derive(Debug, Clone, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct InstallHeartbeatRequest {
467 /// The daemon's persisted install UUID — the platform's upsert key.
468 pub install_id: String,
469 /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
470 /// daemon side) — *not* this crate's version.
471 pub app_version: String,
472 pub os: String,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub os_version: Option<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub arch: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub locale: Option<String>,
479}
480
481/// The platform's view of an install row, echoed back from a heartbeat.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct InstallHeartbeatResponse {
485 pub id: String,
486 pub install_id: String,
487 pub app_version: String,
488 pub os: String,
489 pub os_version: Option<String>,
490 pub arch: Option<String>,
491 pub locale: Option<String>,
492 pub first_seen_at: String,
493 pub last_seen_at: String,
494}
495
496impl Client {
497 /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
498 /// first-run install ping. Detects the host environment internally
499 /// and posts it alongside the caller-supplied `install_id` +
500 /// `app_version`. Associated (not a method) because the endpoint is
501 /// unauthenticated — there's no token, and at first run there's no
502 /// signed-in `Client` to hang it off of.
503 ///
504 /// Though unauthenticated, the request is **signed** with the release
505 /// credential `cred` (a per-version Ed25519 key + master-issued
506 /// certificate the consumer bakes in at build time) so the platform
507 /// can verify it came from a genuine release and reject forged or
508 /// replayed pings — see [`Client::post_public_signed_json`] and
509 /// [`crate::sign`]. The platform needs only the master *public* key to
510 /// verify.
511 ///
512 /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
513 pub async fn install_heartbeat(
514 base_url: &str,
515 install_id: &str,
516 app_version: &str,
517 cred: &ReleaseCredential,
518 ) -> Result<InstallHeartbeatResponse> {
519 let sys = SystemInfo::detect();
520 let body = InstallHeartbeatRequest {
521 install_id: install_id.to_string(),
522 app_version: app_version.to_string(),
523 os: sys.os,
524 os_version: sys.os_version,
525 arch: Some(sys.arch),
526 locale: sys.locale,
527 };
528 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
529 base_url,
530 "/api/voice/installs/heartbeat",
531 &body,
532 cred,
533 )
534 .await
535 }
536}
537
538// ---- Client surface for recordings ----------------------------------------
539//
540// Recordings don't fit the generic `Client::sync` shape cleanly:
541//
542// - the response carries per-item provenance (the platform-stamped
543// `r2Key`, plus whether bytes have already landed) that the
544// daemon needs in order to decide which rows still owe a PUT;
545// - the bytes upload is its own HTTP call (`PUT
546// /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
547//
548// Rather than overloading `SyncEndpoint` to carry these shapes, we
549// expose two inherent methods on `Client` that compose the existing
550// JSON / bytes-PUT primitives.
551
552impl Client {
553 /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
554 /// recording metadata. Returns the per-item `r2Key` the daemon
555 /// should target for the follow-up bytes PUT, and whether bytes
556 /// have already landed for each row.
557 ///
558 /// Batch sizing rules match [`Client::sync`]: the platform rejects
559 /// batches over 100 items; the daemon's uploader chunks at 50.
560 pub async fn sync_recordings(
561 &self,
562 items: &[VoiceRecordingRecord],
563 ) -> Result<VoiceRecordingsSyncResponse> {
564 let stamped = stamp_schema_version::<VoiceRecordings>(items);
565 let body = SyncRequest { items: stamped };
566 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
567 .await
568 }
569
570 /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
571 /// bytes for a recording whose metadata was previously synced via
572 /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
573 /// if `bytes.len()` disagrees with the synced `sizeBytes`.
574 ///
575 /// `source_id` is path-segmented as-is; callers pass the
576 /// daemon-side UUID they used for the metadata sync. Empty /
577 /// path-traversal-shaped ids are not specifically guarded here —
578 /// the platform's Zod schema rejects them server-side, so a
579 /// malformed id surfaces as a 4xx via [`Error::Http`].
580 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
581 if source_id.is_empty() {
582 return Err(Error::BadRequest("source_id must not be empty".into()));
583 }
584 let path = format!("/api/voice/recordings/{source_id}/bytes");
585 self.put_raw_bytes(&path, "audio/wav", bytes).await
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn record_serializes_with_camel_case_keys() {
595 let r = VoiceCallRecord {
596 source_id: "11111111-1111-4111-8111-111111111111".into(),
597 account_id: "22222222-2222-4222-8222-222222222222".into(),
598 direction: VoiceCallDirection::Inbound,
599 party: "+14155550123".into(),
600 ring_at: "2026-05-16T10:00:00Z".into(),
601 answer_at: Some("2026-05-16T10:00:05Z".into()),
602 end_at: "2026-05-16T10:01:00Z".into(),
603 duration_ms: Some(55_000),
604 disposition: VoiceCallDisposition::Answered,
605 end_reason: VoiceCallEndReason::HangupRemote,
606 error: None,
607 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
608 };
609 let s = serde_json::to_string(&r).unwrap();
610 assert!(s.contains("\"sourceId\":"), "{s}");
611 assert!(s.contains("\"accountId\":"), "{s}");
612 assert!(s.contains("\"ringAt\":"), "{s}");
613 assert!(s.contains("\"endAt\":"), "{s}");
614 assert!(s.contains("\"durationMs\":55000"), "{s}");
615 // Optional `error` is None — should be omitted from the wire.
616 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
617 // Envelope flattens to the top of the object — schemaVersion
618 // sits next to the other fields rather than nested under
619 // "envelope". Future resources rely on this layout.
620 assert!(
621 s.contains("\"schemaVersion\":1"),
622 "schemaVersion should flatten: {s}"
623 );
624 // `extras` is None, so the envelope contributes no `extras`
625 // key. Stays out of the row to keep the small/fast path.
626 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
627 }
628
629 #[test]
630 fn record_round_trips_optional_fields() {
631 // An unanswered call has answer_at/duration_ms/error all absent.
632 let raw = r#"{
633 "sourceId": "a",
634 "accountId": "b",
635 "direction": "inbound",
636 "party": "anonymous",
637 "ringAt": "2026-05-16T10:00:00Z",
638 "endAt": "2026-05-16T10:00:30Z",
639 "disposition": "missed",
640 "endReason": "missed"
641 }"#;
642 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
643 assert!(parsed.answer_at.is_none());
644 assert!(parsed.duration_ms.is_none());
645 assert!(parsed.error.is_none());
646 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
647 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
648 }
649
650 #[test]
651 fn query_omits_unset_fields() {
652 let q = VoiceCallsQuery::default();
653 let s = serde_json::to_string(&q).unwrap();
654 // Empty object — every field skipped when None.
655 assert_eq!(
656 s, "{}",
657 "default query should serialize to empty object: {s}"
658 );
659 }
660
661 #[test]
662 fn enum_round_trip_via_json() {
663 // The wire form for each direction/disposition/reason must
664 // match what the daemon and platform expect — this guards
665 // against accidental Rust-side renames.
666 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
667 let s = serde_json::to_string(&d).unwrap();
668 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
669 assert_eq!(d, back);
670 }
671 for d in [
672 VoiceCallDisposition::Answered,
673 VoiceCallDisposition::Missed,
674 VoiceCallDisposition::Rejected,
675 VoiceCallDisposition::Cancelled,
676 VoiceCallDisposition::Failed,
677 ] {
678 let s = serde_json::to_string(&d).unwrap();
679 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
680 assert_eq!(d, back);
681 }
682 for r in [
683 VoiceCallEndReason::HangupLocal,
684 VoiceCallEndReason::HangupRemote,
685 VoiceCallEndReason::RejectedLocal,
686 VoiceCallEndReason::RejectedRemote,
687 VoiceCallEndReason::Missed,
688 VoiceCallEndReason::CancelledLocal,
689 VoiceCallEndReason::ConnectionLost,
690 VoiceCallEndReason::Failed,
691 ] {
692 let s = serde_json::to_string(&r).unwrap();
693 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
694 assert_eq!(r, back);
695 }
696 }
697
698 #[test]
699 fn connection_lost_pins_its_wire_string() {
700 // The platform's sync endpoint validates end reasons against
701 // an exact string list — a rename here would make every
702 // upload from a session-timer teardown bounce with a 400.
703 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
704 assert_eq!(s, "\"connection_lost\"");
705 }
706
707 #[test]
708 fn voice_calls_marker_resource_is_calls() {
709 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
710 }
711
712 #[test]
713 fn record_accepts_unknown_extras_for_forward_compat() {
714 // A newer client shipping a `notes` field that this platform
715 // version doesn't have a column for should round-trip via
716 // the `extras` envelope. The platform persists the blob
717 // verbatim; a future deploy can promote it to a typed
718 // column without data loss.
719 let raw = r#"{
720 "sourceId": "a",
721 "accountId": "b",
722 "direction": "inbound",
723 "party": "anon",
724 "ringAt": "2026-05-16T10:00:00Z",
725 "endAt": "2026-05-16T10:00:30Z",
726 "disposition": "answered",
727 "endReason": "hangup_remote",
728 "schemaVersion": 2,
729 "extras": { "notes": "from staging build" }
730 }"#;
731 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
732 assert_eq!(parsed.envelope.schema_version, Some(2));
733 let extras = parsed.envelope.extras.as_ref().expect("extras present");
734 assert_eq!(extras["notes"], "from staging build");
735 }
736
737 #[test]
738 fn recording_marker_resource_is_recordings() {
739 // Path constant drives the URL in `Client::sync_recordings`;
740 // a rename here would silently 404 against the platform.
741 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
742 }
743
744 #[test]
745 fn recording_record_serializes_with_camel_case_and_envelope() {
746 let r = VoiceRecordingRecord {
747 source_id: "11111111-1111-4111-8111-111111111111".into(),
748 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
749 size_bytes: 44 + 64_000,
750 duration_ms: 2_000,
751 sample_rate: 8_000,
752 channels: 2,
753 created_at: "2026-05-16T10:01:05Z".into(),
754 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
755 };
756 let s = serde_json::to_string(&r).unwrap();
757 // Field-by-field wire contract — these strings are also what
758 // the platform's Zod schema expects.
759 assert!(s.contains("\"sourceId\":"), "{s}");
760 assert!(s.contains("\"callSourceId\":"), "{s}");
761 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
762 assert!(s.contains("\"durationMs\":2000"), "{s}");
763 assert!(s.contains("\"sampleRate\":8000"), "{s}");
764 assert!(s.contains("\"channels\":2"), "{s}");
765 assert!(s.contains("\"createdAt\":"), "{s}");
766 // Envelope flattens to the top of the object, same as VoiceCallRecord.
767 assert!(s.contains("\"schemaVersion\":1"), "{s}");
768 }
769
770 #[test]
771 fn recordings_sync_response_round_trips() {
772 // The richer-than-generic response carries per-item provenance —
773 // the daemon's uploader reads `r2Key` for the bytes follow-up
774 // and `bytesUploaded` to short-circuit when the row already
775 // landed on a previous cycle.
776 let raw = r#"{
777 "accepted": 2,
778 "skipped": 0,
779 "items": [
780 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
781 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
782 ]
783 }"#;
784 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
785 assert_eq!(parsed.accepted, 2);
786 assert_eq!(parsed.items.len(), 2);
787 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
788 assert!(!parsed.items[0].bytes_uploaded);
789 assert!(parsed.items[1].bytes_uploaded);
790 }
791
792 #[test]
793 fn install_heartbeat_request_serializes_with_camel_case_keys() {
794 let req = InstallHeartbeatRequest {
795 install_id: "11111111-1111-4111-8111-111111111111".into(),
796 app_version: "0.0.21".into(),
797 os: "macos".into(),
798 os_version: Some("15.5.0".into()),
799 arch: Some("aarch64".into()),
800 locale: Some("en-NZ".into()),
801 };
802 let s = serde_json::to_string(&req).unwrap();
803 assert!(s.contains("\"installId\":"), "{s}");
804 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
805 assert!(s.contains("\"os\":\"macos\""), "{s}");
806 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
807 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
808 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
809 }
810
811 #[test]
812 fn install_heartbeat_request_omits_absent_optional_fields() {
813 // A host where the OS version / locale probe came up empty
814 // shouldn't send `null` — keeping the keys out lets the
815 // platform's Zod `.optional()` accept the body and the column
816 // stay NULL rather than the string "null".
817 let req = InstallHeartbeatRequest {
818 install_id: "x".into(),
819 app_version: "0.0.21".into(),
820 os: "linux".into(),
821 os_version: None,
822 arch: None,
823 locale: None,
824 };
825 let s = serde_json::to_string(&req).unwrap();
826 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
827 assert!(!s.contains("arch"), "arch should be omitted: {s}");
828 assert!(!s.contains("locale"), "locale should be omitted: {s}");
829 }
830
831 #[test]
832 fn install_heartbeat_response_parses_platform_shape() {
833 let raw = r#"{
834 "id": "abc-123",
835 "installId": "11111111-1111-4111-8111-111111111111",
836 "appVersion": "0.0.21",
837 "os": "macos",
838 "osVersion": "15.5.0",
839 "arch": "aarch64",
840 "locale": null,
841 "firstSeenAt": "2026-05-31T10:00:00.000Z",
842 "lastSeenAt": "2026-05-31T10:00:00.000Z"
843 }"#;
844 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
845 assert_eq!(parsed.id, "abc-123");
846 assert_eq!(parsed.app_version, "0.0.21");
847 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
848 assert!(parsed.locale.is_none());
849 }
850
851 #[test]
852 fn system_info_detect_fills_os_and_arch() {
853 // os / arch come from compile-time consts, so they're always
854 // non-empty on every supported target. os_version / locale are
855 // best-effort and intentionally not asserted.
856 let sys = SystemInfo::detect();
857 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
858 assert!(
859 !sys.arch.is_empty(),
860 "arch should be a non-empty target string"
861 );
862 }
863
864 #[test]
865 fn transcripts_marker_resource_is_transcripts() {
866 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
867 }
868
869 #[test]
870 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
871 let r = VoiceTranscriptRecord {
872 source_id: "1".into(),
873 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
874 channel: VoiceTranscriptChannel::Remote,
875 ts_ms: 100,
876 end_ms: 1_500,
877 text: "hello".into(),
878 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
879 };
880 let s = serde_json::to_string(&r).unwrap();
881 assert!(s.contains("\"sourceId\":"), "{s}");
882 assert!(s.contains("\"callSourceId\":"), "{s}");
883 // The channel enum is wire-stable snake_case — matches the
884 // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
885 assert!(s.contains("\"channel\":\"remote\""), "{s}");
886 assert!(s.contains("\"tsMs\":100"), "{s}");
887 assert!(s.contains("\"endMs\":1500"), "{s}");
888 assert!(s.contains("\"text\":\"hello\""), "{s}");
889 assert!(s.contains("\"schemaVersion\":1"), "{s}");
890 }
891
892 // ---- VoiceAccounts ----
893
894 fn sample_account() -> VoiceAccountRecord {
895 VoiceAccountRecord {
896 source_id: "11111111-1111-4111-8111-111111111111".into(),
897 enabled: true,
898 display_name: "Work line".into(),
899 username: "alice".into(),
900 domain: "sip.example.com".into(),
901 auth_username: Some("alice-auth".into()),
902 server: Some("sip.example.com".into()),
903 port: Some(5060),
904 transport: VoiceTransport::Udp,
905 register_expires: 60,
906 keepalive_secs: Some(50),
907 disclosure_enabled: true,
908 updated_at: "2026-06-20T10:00:00Z".into(),
909 deleted_at: None,
910 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
911 }
912 }
913
914 #[test]
915 fn accounts_marker_resource_is_accounts() {
916 // Path constant drives the URL in `Client::sync` / `Client::list`;
917 // a rename here would silently 404 against the platform.
918 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
919 }
920
921 #[test]
922 fn account_record_serializes_with_camel_case_and_envelope() {
923 let s = serde_json::to_string(&sample_account()).unwrap();
924 // Field-by-field wire contract — also what the platform's Zod
925 // schema expects.
926 assert!(s.contains("\"sourceId\":"), "{s}");
927 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
928 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
929 assert!(s.contains("\"registerExpires\":60"), "{s}");
930 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
931 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
932 assert!(s.contains("\"transport\":\"udp\""), "{s}");
933 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
934 // A live line carries no tombstone.
935 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
936 // The secret never crosses this wire, by construction.
937 assert!(!s.contains("password"), "no password field: {s}");
938 // Envelope flattens to the top, same as the other resources.
939 assert!(s.contains("\"schemaVersion\":1"), "{s}");
940 }
941
942 #[test]
943 fn account_tombstone_serializes_deleted_at() {
944 // A soft-delete rides as an upsert with deletedAt set — the
945 // delete-propagation mechanism (doc 40).
946 let mut r = sample_account();
947 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
948 let s = serde_json::to_string(&r).unwrap();
949 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
950 }
951
952 #[test]
953 fn account_record_round_trips_optional_fields() {
954 // A minimal line — no auth username, server, port, keepalive, or
955 // tombstone — should parse with those all absent.
956 let raw = r#"{
957 "sourceId": "a",
958 "enabled": false,
959 "displayName": "Cheap trunk",
960 "username": "u",
961 "domain": "d",
962 "transport": "tcp",
963 "registerExpires": 120,
964 "disclosureEnabled": false,
965 "updatedAt": "2026-06-20T10:00:00Z"
966 }"#;
967 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
968 assert!(!parsed.enabled);
969 assert!(parsed.auth_username.is_none());
970 assert!(parsed.server.is_none());
971 assert!(parsed.port.is_none());
972 assert!(parsed.keepalive_secs.is_none());
973 assert!(parsed.deleted_at.is_none());
974 assert_eq!(parsed.transport, VoiceTransport::Tcp);
975 assert_eq!(parsed.register_expires, 120);
976 }
977
978 #[test]
979 fn voice_transport_round_trips_via_json() {
980 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
981 let s = serde_json::to_string(&t).unwrap();
982 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
983 assert_eq!(t, back);
984 }
985 // Pin the wire strings — the daemon's `TransportKind` and the
986 // platform's Zod enum both depend on these exact tokens.
987 assert_eq!(
988 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
989 "\"udp\""
990 );
991 assert_eq!(
992 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
993 "\"tcp\""
994 );
995 }
996
997 #[test]
998 fn accounts_query_omits_unset_and_serializes_include_deleted() {
999 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1000 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1001 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1002 include_deleted: Some(true),
1003 })
1004 .unwrap();
1005 assert!(
1006 with_deleted.contains("\"includeDeleted\":true"),
1007 "{with_deleted}"
1008 );
1009 }
1010
1011 #[test]
1012 fn account_record_accepts_unknown_extras_for_forward_compat() {
1013 // A newer client shipping a field this platform version lacks a
1014 // column for round-trips via the `extras` envelope.
1015 let raw = r#"{
1016 "sourceId": "a",
1017 "enabled": true,
1018 "displayName": "x",
1019 "username": "u",
1020 "domain": "d",
1021 "transport": "udp",
1022 "registerExpires": 60,
1023 "disclosureEnabled": true,
1024 "updatedAt": "2026-06-20T10:00:00Z",
1025 "schemaVersion": 2,
1026 "extras": { "ringtone": "classic" }
1027 }"#;
1028 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1029 assert_eq!(parsed.envelope.schema_version, Some(2));
1030 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1031 assert_eq!(extras["ringtone"], "classic");
1032 }
1033}