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::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    /// We blind-transferred the call to a third party (RFC 3515) and
61    /// dropped our own leg once the target answered. Distinct from
62    /// `HangupLocal`: the user didn't hang up, they handed the call off.
63    /// The destination is carried alongside in
64    /// [`VoiceCallRecord::transfer_target`]. Rows with this reason still
65    /// carry [`VoiceCallDisposition::Answered`].
66    TransferredLocal,
67    /// An established call torn down because its connection died —
68    /// the daemon's RFC 4028 session keepalive stopped getting
69    /// answers (peer crashed, NAT binding dropped). Distinct from
70    /// `HangupLocal`: the user didn't end this call. Rows with this
71    /// reason still carry [`VoiceCallDisposition::Answered`].
72    ConnectionLost,
73    Failed,
74}
75
76/// One historical call as it crosses the wire from the daemon up to the
77/// platform.
78///
79/// Mirrors the daemon's local `CallRecord` (see
80/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
81/// the daemon's local primary key (`id`) is shipped as `source_id`
82/// because the platform allocates its own row id and treats the
83/// daemon-side UUID as the idempotency key.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct VoiceCallRecord {
87    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
88    /// upsert key — re-syncing the same id is a no-op.
89    pub source_id: String,
90    /// SIP account UUID on the daemon side. Opaque to the platform.
91    pub account_id: String,
92    pub direction: VoiceCallDirection,
93    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
94    /// IDs, display names, and SIP URIs all land here.
95    pub party: String,
96    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
97    pub ring_at: String,
98    /// RFC 3339. Present only when the call reached the answered
99    /// state.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub answer_at: Option<String>,
102    /// RFC 3339. Terminal timestamp; the platform uses this as the
103    /// list cursor.
104    pub end_at: String,
105    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
106    /// were never answered.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub duration_ms: Option<i64>,
109    pub disposition: VoiceCallDisposition,
110    pub end_reason: VoiceCallEndReason,
111    /// Free-text error, populated only when `disposition == Failed`.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub error: Option<String>,
114    /// Visibility tier of any *active* (not revoked / expired) share on this
115    /// call's recording, or `None` when it isn't shared. Read-only: the
116    /// platform sets it on list (`GET /api/voice/calls`) and detail responses
117    /// so a consumer can badge the row "Public" / "Invited only"; it is
118    /// skipped on serialize, so syncing a call never sends it. `Private` never
119    /// appears here — an unshared call is `None`.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub share_visibility: Option<ShareVisibility>,
122    /// Where a transferred call was sent — the number or SIP address the
123    /// far end was asked to call (RFC 3515 `Refer-To`). Set only when
124    /// `end_reason == TransferredLocal`; `None` for every other call.
125    /// Unlike `share_visibility` this is daemon-owned data, so it *is*
126    /// sent on sync (serialized when present) and echoed back on read.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub transfer_target: Option<String>,
129    /// Version + forward-compat fields shared by every sync record.
130    /// Flattened so `schemaVersion` and `extras` sit at the top of
131    /// the JSON object alongside the other columns. See
132    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
133    /// compatibility".
134    #[serde(flatten, default)]
135    pub envelope: SyncEnvelope,
136}
137
138/// Query params for `GET /api/voice/calls`. All fields optional — the
139/// default returns the newest page.
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct VoiceCallsQuery {
143    /// RFC 3339 cursor; rows with `end_at < before` are returned.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub before: Option<String>,
146    /// 1..=200. Server default is 50.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub limit: Option<u32>,
149}
150
151/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
152///
153/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
154pub struct VoiceCalls;
155
156impl SyncEndpoint for VoiceCalls {
157    const RESOURCE: &'static str = "calls";
158    type Record = VoiceCallRecord;
159    type Query = VoiceCallsQuery;
160}
161
162impl HasSyncEnvelope for VoiceCallRecord {
163    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
164        &mut self.envelope
165    }
166}
167
168// ---- VoiceRecordings ------------------------------------------------------
169
170/// One per-call recording's metadata as it crosses the wire from the
171/// daemon up to the platform. The WAV bytes ride on a separate
172/// follow-up call ([`Client::upload_recording_bytes`]) so the
173/// idempotent metadata sync stays small and a flaky bytes upload
174/// doesn't force the daemon to re-ship the row.
175///
176/// Mirrors the daemon's `RecordingArtifact` (see
177/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
178/// rename: the daemon's local id (`id`) ships as `source_id` because
179/// the platform allocates its own row id and treats the daemon-side
180/// UUID as the idempotency key (same convention as
181/// [`VoiceCallRecord`]).
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct VoiceRecordingRecord {
185    /// Daemon-generated UUID for this recording artifact. Upsert key
186    /// on the platform side.
187    pub source_id: String,
188    /// Daemon's `calls.id` — the call this recording belongs to.
189    /// The platform stores both so the /voice/calls history page can
190    /// link a call to its recording without a separate join table.
191    pub call_source_id: String,
192    /// Byte length of the WAV file the daemon will PUT in the follow-
193    /// up bytes call. The platform refuses a PUT whose body length
194    /// disagrees.
195    pub size_bytes: u64,
196    pub duration_ms: u64,
197    pub sample_rate: u32,
198    pub channels: u16,
199    /// RFC 3339 timestamp the daemon stamped on the artifact at
200    /// finalize time. Drives the platform's `/voice/recordings` GET
201    /// cursor.
202    pub created_at: String,
203    #[serde(flatten, default)]
204    pub envelope: SyncEnvelope,
205}
206
207/// Query params for `GET /api/voice/recordings`.
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct VoiceRecordingsQuery {
211    /// RFC 3339 cursor; rows with `created_at < before` are returned.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub before: Option<String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub limit: Option<u32>,
216}
217
218/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
219///
220/// The corresponding bytes-upload endpoint
221/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
222/// [`Client::upload_recording_bytes`] — it doesn't fit the
223/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
224/// inherent method on `Client`.
225pub struct VoiceRecordings;
226
227impl SyncEndpoint for VoiceRecordings {
228    const RESOURCE: &'static str = "recordings";
229    type Record = VoiceRecordingRecord;
230    type Query = VoiceRecordingsQuery;
231}
232
233impl HasSyncEnvelope for VoiceRecordingRecord {
234    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
235        &mut self.envelope
236    }
237}
238
239/// One item in the platform's response to
240/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
241/// key the platform stamped (so a subsequent bytes PUT can target it)
242/// without re-deriving it, and check whether bytes have already
243/// landed on a prior cycle (so the daemon can mark the local row
244/// synced without re-uploading the WAV).
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct VoiceRecordingSyncItem {
248    pub source_id: String,
249    pub r2_key: String,
250    pub bytes_uploaded: bool,
251}
252
253/// Full response from `POST /api/voice/recordings/sync`. Superset of
254/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct VoiceRecordingsSyncResponse {
258    pub accepted: u32,
259    pub skipped: u32,
260    pub items: Vec<VoiceRecordingSyncItem>,
261}
262
263// ---- VoiceTranscripts -----------------------------------------------------
264
265/// Wire-stable transcript channel tag. Matches the daemon's
266/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum VoiceTranscriptChannel {
270    /// Local mic audio — what the user said.
271    Local,
272    /// Received RTP audio — what the remote party said.
273    Remote,
274}
275
276/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
277/// crosses the wire. Each segment is a row on the daemon side
278/// (`transcripts` table); the daemon batches a slice of them per
279/// upload and the platform upserts per (user_id, source_id).
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct VoiceTranscriptRecord {
283    /// Daemon-side row id, formatted as text (the column is an
284    /// autoincrement integer on SQLite). Stable per (call, segment)
285    /// so re-shipping converges.
286    pub source_id: String,
287    /// Daemon's `calls.id` — the call this segment belongs to.
288    pub call_source_id: String,
289    pub channel: VoiceTranscriptChannel,
290    /// Start of the segment in milliseconds relative to the start of
291    /// the call's audio stream (not wall-clock).
292    pub ts_ms: i64,
293    /// End of the segment, same reference frame as `ts_ms`.
294    pub end_ms: i64,
295    /// Recognised text. Free-form; the platform stores it verbatim.
296    pub text: String,
297    #[serde(flatten, default)]
298    pub envelope: SyncEnvelope,
299}
300
301/// Query params for `GET /api/voice/transcripts` — required
302/// `call_source_id` (the endpoint refuses a flat list).
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct VoiceTranscriptsQuery {
306    pub call_source_id: String,
307}
308
309/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
310pub struct VoiceTranscripts;
311
312impl SyncEndpoint for VoiceTranscripts {
313    const RESOURCE: &'static str = "transcripts";
314    type Record = VoiceTranscriptRecord;
315    type Query = VoiceTranscriptsQuery;
316}
317
318impl HasSyncEnvelope for VoiceTranscriptRecord {
319    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
320        &mut self.envelope
321    }
322}
323
324// ---- VoiceAccounts --------------------------------------------------------
325
326/// SIP transport for a synced account line. Wire-stable snake_case;
327/// mirrors the daemon's `TransportKind`.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "snake_case")]
330pub enum VoiceTransport {
331    Udp,
332    Tcp,
333}
334
335/// One SIP account line's *configuration* as it crosses the wire from a
336/// device up to the platform and back down to another device
337/// (`wavekat-voice/docs/40-account-config-sync.md`).
338///
339/// Unlike calls / recordings / transcripts — which are immutable,
340/// one-way pushes — account config is **mutable and bidirectional**: a
341/// line is edited, toggled, renamed, and deleted, and those changes must
342/// restore onto a second device. The same idempotent
343/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
344/// every kind of change here; a *delete* is a soft-delete that rides as
345/// an upsert with `deleted_at` set, because a hard DELETE can't sync
346/// under a "push the row" model — once the row is gone there's nothing
347/// left to push.
348///
349/// **No secret field, by construction.** The SIP password never appears
350/// on this wire. Config sync (policy levels 1–2) keeps the credential
351/// device-local, and the end-to-end-encrypted secret path (level 3)
352/// ships its ciphertext through a *separate* opaque resource, never as a
353/// field here. Omitting it means level 3 can't be populated by accident
354/// before it exists.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct VoiceAccountRecord {
358    /// Daemon-side account UUID (`accounts.id`). The platform's
359    /// `(user_id, source_id)` upsert key — re-syncing the same id
360    /// updates the row in place (mutable), unlike the immutable
361    /// resources where a re-sync is a no-op.
362    pub source_id: String,
363    /// Whether the line registers on daemon boot. Pausing a line is a
364    /// portable preference, so it rides along.
365    pub enabled: bool,
366    pub display_name: String,
367    pub username: String,
368    pub domain: String,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub auth_username: Option<String>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub server: Option<String>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub port: Option<u16>,
375    pub transport: VoiceTransport,
376    pub register_expires: u32,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub keepalive_secs: Option<u32>,
379    /// Record-disclosure beep toggle — a column on the account row, so
380    /// it rides along for free (the account-portable taxonomy in doc 40).
381    pub disclosure_enabled: bool,
382    /// RFC 3339 last-modification time — the **last-write-wins key**. On
383    /// conflict the platform (and a pulling client) keep the copy with
384    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
385    /// deferred until users actually report lost edits (doc 40).
386    pub updated_at: String,
387    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
388    /// was deleted on some device at that time. A tombstone syncs like
389    /// any other mutation so the delete propagates to other devices,
390    /// then is reaped locally once confirmed. The platform retains
391    /// tombstones so a late-syncing device still learns about the delete.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub deleted_at: Option<String>,
394    /// Version + forward-compat fields shared by every sync record.
395    #[serde(flatten, default)]
396    pub envelope: SyncEnvelope,
397}
398
399/// Query params for `GET /api/voice/accounts`. All fields optional.
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct VoiceAccountsQuery {
403    /// Include soft-deleted tombstones in the response. Absent / false
404    /// returns only live lines — the restore-grade pull a fresh device
405    /// wants. A delta-syncing device sets this `true` to also learn
406    /// about deletes made elsewhere (doc 40).
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub include_deleted: Option<bool>,
409}
410
411/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
412///
413/// Accounts are the first *mutable, bidirectional* sync resource, but
414/// the wire shape is the same idempotent upsert the immutable resources
415/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
416/// was reserved for exactly this case — so no new HTTP plumbing is
417/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
418/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
419pub struct VoiceAccounts;
420
421impl SyncEndpoint for VoiceAccounts {
422    const RESOURCE: &'static str = "accounts";
423    type Record = VoiceAccountRecord;
424    type Query = VoiceAccountsQuery;
425}
426
427impl HasSyncEnvelope for VoiceAccountRecord {
428    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
429        &mut self.envelope
430    }
431}
432
433// ---- Anonymous install heartbeat ------------------------------------------
434//
435// A first-run / per-launch ping the desktop daemon fires *before* (and
436// independently of) any platform sign-in, so the platform can count
437// installs and track version / OS adoption for users who never sign in.
438// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
439// and upserts a row keyed by `install_id` alone (no user) — distinct
440// from the authenticated `voice_clients` heartbeat, which is keyed by
441// `(user, install_id)`.
442//
443// The environment fields (os / os_version / arch / locale) are gathered
444// *here*, inside the client crate, rather than on the consumer side:
445// the daemon only owns the two values this crate genuinely cannot
446// discover — the persisted `install_id` and its own app version.
447
448/// Best-effort snapshot of the host environment, detected at call time.
449/// Every field is best-effort; a probe that fails contributes `None`
450/// (or, for the always-available `os` / `arch`, the compile-time
451/// target) rather than failing the heartbeat.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct SystemInfo {
454    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
455    pub os: String,
456    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
457    /// can't determine it.
458    pub os_version: Option<String>,
459    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
460    pub arch: String,
461    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
462    /// undetectable (common for GUI-launched apps on some platforms).
463    pub locale: Option<String>,
464}
465
466impl SystemInfo {
467    /// Probe the current host. Cheap enough to call per heartbeat; we
468    /// don't cache so a locale change between launches is reflected.
469    pub fn detect() -> Self {
470        let os_version = match os_info::get().version() {
471            os_info::Version::Unknown => None,
472            v => Some(v.to_string()),
473        };
474        SystemInfo {
475            os: std::env::consts::OS.to_string(),
476            os_version,
477            arch: std::env::consts::ARCH.to_string(),
478            locale: sys_locale::get_locale(),
479        }
480    }
481}
482
483/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
484/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
485/// environment fields from [`SystemInfo::detect`].
486#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase")]
488pub struct InstallHeartbeatRequest {
489    /// The daemon's persisted install UUID — the platform's upsert key.
490    pub install_id: String,
491    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
492    /// daemon side) — *not* this crate's version.
493    pub app_version: String,
494    pub os: String,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub os_version: Option<String>,
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub arch: Option<String>,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub locale: Option<String>,
501}
502
503/// The platform's view of an install row, echoed back from a heartbeat.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(rename_all = "camelCase")]
506pub struct InstallHeartbeatResponse {
507    pub id: String,
508    pub install_id: String,
509    pub app_version: String,
510    pub os: String,
511    pub os_version: Option<String>,
512    pub arch: Option<String>,
513    pub locale: Option<String>,
514    pub first_seen_at: String,
515    pub last_seen_at: String,
516}
517
518impl Client {
519    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
520    /// first-run install ping. Detects the host environment internally
521    /// and posts it alongside the caller-supplied `install_id` +
522    /// `app_version`. Associated (not a method) because the endpoint is
523    /// unauthenticated — there's no token, and at first run there's no
524    /// signed-in `Client` to hang it off of.
525    ///
526    /// Though unauthenticated, the request is **signed** with the release
527    /// credential `cred` (a per-version Ed25519 key + master-issued
528    /// certificate the consumer bakes in at build time) so the platform
529    /// can verify it came from a genuine release and reject forged or
530    /// replayed pings — see [`Client::post_public_signed_json`] and
531    /// [`crate::sign`]. The platform needs only the master *public* key to
532    /// verify.
533    ///
534    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
535    pub async fn install_heartbeat(
536        base_url: &str,
537        install_id: &str,
538        app_version: &str,
539        cred: &ReleaseCredential,
540    ) -> Result<InstallHeartbeatResponse> {
541        let sys = SystemInfo::detect();
542        let body = InstallHeartbeatRequest {
543            install_id: install_id.to_string(),
544            app_version: app_version.to_string(),
545            os: sys.os,
546            os_version: sys.os_version,
547            arch: Some(sys.arch),
548            locale: sys.locale,
549        };
550        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
551            base_url,
552            "/api/voice/installs/heartbeat",
553            &body,
554            cred,
555        )
556        .await
557    }
558}
559
560// ---- Client surface for recordings ----------------------------------------
561//
562// Recordings don't fit the generic `Client::sync` shape cleanly:
563//
564//   - the response carries per-item provenance (the platform-stamped
565//     `r2Key`, plus whether bytes have already landed) that the
566//     daemon needs in order to decide which rows still owe a PUT;
567//   - the bytes upload is its own HTTP call (`PUT
568//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
569//
570// Rather than overloading `SyncEndpoint` to carry these shapes, we
571// expose two inherent methods on `Client` that compose the existing
572// JSON / bytes-PUT primitives.
573
574impl Client {
575    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
576    /// recording metadata. Returns the per-item `r2Key` the daemon
577    /// should target for the follow-up bytes PUT, and whether bytes
578    /// have already landed for each row.
579    ///
580    /// Batch sizing rules match [`Client::sync`]: the platform rejects
581    /// batches over 100 items; the daemon's uploader chunks at 50.
582    pub async fn sync_recordings(
583        &self,
584        items: &[VoiceRecordingRecord],
585    ) -> Result<VoiceRecordingsSyncResponse> {
586        let stamped = stamp_schema_version::<VoiceRecordings>(items);
587        let body = SyncRequest { items: stamped };
588        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
589            .await
590    }
591
592    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
593    /// bytes for a recording whose metadata was previously synced via
594    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
595    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
596    ///
597    /// `source_id` is path-segmented as-is; callers pass the
598    /// daemon-side UUID they used for the metadata sync. Empty /
599    /// path-traversal-shaped ids are not specifically guarded here —
600    /// the platform's Zod schema rejects them server-side, so a
601    /// malformed id surfaces as a 4xx via [`Error::Http`].
602    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
603        if source_id.is_empty() {
604            return Err(Error::BadRequest("source_id must not be empty".into()));
605        }
606        let path = format!("/api/voice/recordings/{source_id}/bytes");
607        self.put_raw_bytes(&path, "audio/wav", bytes).await
608    }
609}
610
611// ---- Recording sharing ----------------------------------------------------
612//
613// Sharing is a *command* — mutate one recording's share state and get a
614// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
615// exists for (see wavekat-voice doc 38). So it's a typed method pair on
616// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
617//
618// The desktop daemon keeps only a *mirror* of what these return; the
619// platform is authoritative for who may open a share. See
620// `wavekat-voice/docs/38-share-a-recording.md`.
621
622/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
623/// snake_case strings — the platform's Zod schema validates against this
624/// exact list, so a rename here would bounce every share command with a 400.
625///
626/// - `Private` — owner only (the default; "not shared").
627/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
628///   recipient must be signed in as an invited identity ("protected by login").
629/// - `Public` — anyone holding the capability link, no sign-in.
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(rename_all = "snake_case")]
632pub enum ShareVisibility {
633    Private,
634    Restricted,
635    Public,
636}
637
638/// How a shared recording's caller/callee identity (the call's `party`) is
639/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
640/// enum, so a rename here bounces a share command with a 400.
641///
642/// - `Full` — hidden behind a neutral direction label ("Inbound call").
643/// - `Partial` — best-effort redaction (keeps shape, drops the value).
644/// - `None` — the raw `party` is shown.
645///
646/// Absent on the wire → the platform defaults to `Partial` (identity
647/// masked) — privacy-forward without fully erasing the caller. See
648/// `wavekat-platform` docs/14.
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(rename_all = "snake_case")]
651pub enum PartyMasking {
652    Full,
653    Partial,
654    None,
655}
656
657/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
658/// recording's share. The recording must already be synced (metadata +
659/// bytes) or the platform returns 404.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661#[serde(rename_all = "camelCase")]
662pub struct ShareRecordingRequest {
663    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
664    /// the URL path; carried in the struct so callers pass one value.
665    pub recording_source_id: String,
666    pub visibility: ShareVisibility,
667    /// Restricted tier — the WaveKat-account emails allowed to open the
668    /// share. Ignored (and omitted) for `Private` / `Public`.
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub invited_emails: Option<Vec<String>>,
671    /// Per-share visibility controls (platform docs/14) — what a viewer may
672    /// see. Each is omitted when unset; the platform then applies its
673    /// privacy-forward default (identity masked, transcript hidden, audio
674    /// shown, download off). NB the platform treats the request as the
675    /// *full* desired state, so an omitted control is reset to its default,
676    /// not preserved from a prior share — send all of them when editing an
677    /// existing share's controls.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub party_masking: Option<PartyMasking>,
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub show_transcript: Option<bool>,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub show_audio: Option<bool>,
684    /// Whether a viewer may *download* the WAV, distinct from hearing it.
685    /// Off by default and only meaningful while `show_audio` is true — the
686    /// platform forces it off otherwise (you can't save what you can't
687    /// hear). A soft control: it hides the viewer's Download affordance,
688    /// not the bytes a listener already fetches to play.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub allow_download: Option<bool>,
691    /// Per-channel playback defaults — which side is *audible by default*
692    /// in the viewer's player (docs/14). A call has two channels: `local`
693    /// (the owner's microphone, "your side") and `remote` (the other
694    /// party, "their side"). `true` means that side starts muted; the
695    /// viewer can still un-mute it, and the audio file is unchanged — this
696    /// is only the player's starting state. Each is omitted when unset, in
697    /// which case the platform defaults to audible (`false`). Only
698    /// meaningful while `show_audio` is true; ignored when audio is hidden.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub default_mute_local: Option<bool>,
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub default_mute_remote: Option<bool>,
703    /// Phase 2 — out-of-band password gate. Omitted when unset.
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub password: Option<String>,
706    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub expires_at: Option<String>,
709}
710
711/// The platform's response to a successful share command. `share_url` is
712/// the full https link the user copies; `token` is the opaque capability
713/// identifier embedded in it (returned separately so the daemon can store
714/// it for display without re-parsing the URL).
715#[derive(Debug, Clone, Serialize, Deserialize)]
716#[serde(rename_all = "camelCase")]
717pub struct ShareRecordingResponse {
718    pub visibility: ShareVisibility,
719    pub token: String,
720    pub share_url: String,
721    /// RFC 3339 — when the recording was first shared.
722    pub shared_at: String,
723    /// Effective visibility controls the platform stored (docs/14). Optional
724    /// for tolerance — a platform predating the feature omits them, in which
725    /// case the daemon should assume the defaults (identity masked, transcript
726    /// hidden, audio shown, download off).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub party_masking: Option<PartyMasking>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub show_transcript: Option<bool>,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub show_audio: Option<bool>,
733    /// Effective download permission — `show_audio && allow_download`, so
734    /// it's never true when the audio is hidden. Absent on a platform
735    /// predating the control (assume off).
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub allow_download: Option<bool>,
738    /// Effective per-channel playback defaults the platform stored — which
739    /// side starts muted in the viewer's player (docs/14). Absent on a
740    /// platform predating the control (assume audible, `false`).
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub default_mute_local: Option<bool>,
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub default_mute_remote: Option<bool>,
745}
746
747/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
748/// *authoritative* current share state for an owned recording. The POST
749/// reply omits the invited-email list and a local mirror can't reflect a
750/// share changed from another device, so the desktop "who can open this"
751/// panel reads here.
752///
753/// A recording that was never shared (or whose share is revoked / expired)
754/// comes back as [`ShareVisibility::Private`] with the optional fields
755/// absent — the same "not shared" state DELETE leaves behind.
756#[derive(Debug, Clone, Serialize, Deserialize)]
757#[serde(rename_all = "camelCase")]
758pub struct ShareStateResponse {
759    pub visibility: ShareVisibility,
760    /// Absent when `visibility == Private` (nothing is shared).
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub token: Option<String>,
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub share_url: Option<String>,
765    /// RFC 3339 — when the recording was first shared. Absent when private.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub shared_at: Option<String>,
768    /// The restricted tier's audience (lowercased, de-duped). Present
769    /// (possibly empty) only for [`ShareVisibility::Restricted`].
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub invited_emails: Option<Vec<String>>,
772    /// Per-share visibility controls (docs/14). Present for a live share;
773    /// absent when `Private` (nothing is shared, so no controls apply).
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub party_masking: Option<PartyMasking>,
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub show_transcript: Option<bool>,
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub show_audio: Option<bool>,
780    /// Effective download permission — `show_audio && allow_download`, so
781    /// never true when the audio is hidden. Absent when private.
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub allow_download: Option<bool>,
784    /// Effective per-channel playback defaults — which side starts muted in
785    /// the viewer's player (docs/14). Absent when private.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub default_mute_local: Option<bool>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub default_mute_remote: Option<bool>,
790}
791
792impl Client {
793    /// `POST /api/voice/recordings/{id}/share` — create or update a share
794    /// for an already-synced recording. Returns the capability link + token
795    /// the desktop UI puts on the clipboard.
796    ///
797    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
798    /// to share a recording the caller doesn't own surfaces as
799    /// [`Error::Http`] with status 404 — existence doesn't leak.
800    pub async fn share_recording(
801        &self,
802        req: &ShareRecordingRequest,
803    ) -> Result<ShareRecordingResponse> {
804        if req.recording_source_id.is_empty() {
805            return Err(Error::BadRequest(
806                "recording_source_id must not be empty".into(),
807            ));
808        }
809        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
810        self.post_json::<ShareRecordingResponse, _>(&path, req)
811            .await
812    }
813
814    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
815    /// share state for an owned recording, including the restricted tier's
816    /// invited emails (which the share command's reply omits). Like
817    /// [`share_recording`](Self::share_recording), a recording the caller
818    /// doesn't own surfaces as [`Error::Http`] with status 404.
819    pub async fn get_recording_share(
820        &self,
821        recording_source_id: &str,
822    ) -> Result<ShareStateResponse> {
823        if recording_source_id.is_empty() {
824            return Err(Error::BadRequest(
825                "recording_source_id must not be empty".into(),
826            ));
827        }
828        let path = format!("/api/voice/recordings/{recording_source_id}/share");
829        self.get_json::<ShareStateResponse>(&path).await
830    }
831
832    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
833    /// recording reverts to Private and any outstanding link returns 410.
834    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
835        if recording_source_id.is_empty() {
836            return Err(Error::BadRequest(
837                "recording_source_id must not be empty".into(),
838            ));
839        }
840        let path = format!("/api/voice/recordings/{recording_source_id}/share");
841        self.delete(&path).await
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    #[test]
850    fn share_visibility_types_are_reachable_from_the_crate_root() {
851        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
852        // module but left out of the crate-root `pub use voice::{…}`, and the
853        // module is private — so a consumer (`wavekat-voice`) couldn't name
854        // the type to build a `ShareRecordingRequest`. Pin every share-control
855        // type to the root path so dropping one fails to compile here, not in
856        // a downstream crate. The body never runs; reachability is the test.
857        #[allow(dead_code)]
858        fn _reachable() {
859            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
860            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
861            let _: fn(&crate::ShareRecordingRequest) = |_| {};
862            let _: fn(&crate::ShareRecordingResponse) = |_| {};
863        }
864    }
865
866    #[test]
867    fn record_serializes_with_camel_case_keys() {
868        let r = VoiceCallRecord {
869            source_id: "11111111-1111-4111-8111-111111111111".into(),
870            account_id: "22222222-2222-4222-8222-222222222222".into(),
871            direction: VoiceCallDirection::Inbound,
872            party: "+14155550123".into(),
873            ring_at: "2026-05-16T10:00:00Z".into(),
874            answer_at: Some("2026-05-16T10:00:05Z".into()),
875            end_at: "2026-05-16T10:01:00Z".into(),
876            duration_ms: Some(55_000),
877            disposition: VoiceCallDisposition::Answered,
878            end_reason: VoiceCallEndReason::HangupRemote,
879            error: None,
880            share_visibility: None,
881            transfer_target: None,
882            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
883        };
884        let s = serde_json::to_string(&r).unwrap();
885        assert!(s.contains("\"sourceId\":"), "{s}");
886        assert!(s.contains("\"accountId\":"), "{s}");
887        assert!(s.contains("\"ringAt\":"), "{s}");
888        assert!(s.contains("\"endAt\":"), "{s}");
889        assert!(s.contains("\"durationMs\":55000"), "{s}");
890        // Optional `error` is None — should be omitted from the wire.
891        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
892        // Optional `transferTarget` is None here — omitted from the wire,
893        // exactly like a non-transferred call ships.
894        assert!(
895            !s.contains("\"transferTarget\""),
896            "transferTarget should be omitted: {s}"
897        );
898        // Envelope flattens to the top of the object — schemaVersion
899        // sits next to the other fields rather than nested under
900        // "envelope". Future resources rely on this layout.
901        assert!(
902            s.contains("\"schemaVersion\":1"),
903            "schemaVersion should flatten: {s}"
904        );
905        // `extras` is None, so the envelope contributes no `extras`
906        // key. Stays out of the row to keep the small/fast path.
907        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
908    }
909
910    #[test]
911    fn record_round_trips_optional_fields() {
912        // An unanswered call has answer_at/duration_ms/error all absent.
913        let raw = r#"{
914            "sourceId": "a",
915            "accountId": "b",
916            "direction": "inbound",
917            "party": "anonymous",
918            "ringAt": "2026-05-16T10:00:00Z",
919            "endAt": "2026-05-16T10:00:30Z",
920            "disposition": "missed",
921            "endReason": "missed"
922        }"#;
923        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
924        assert!(parsed.answer_at.is_none());
925        assert!(parsed.duration_ms.is_none());
926        assert!(parsed.error.is_none());
927        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
928        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
929    }
930
931    #[test]
932    fn query_omits_unset_fields() {
933        let q = VoiceCallsQuery::default();
934        let s = serde_json::to_string(&q).unwrap();
935        // Empty object — every field skipped when None.
936        assert_eq!(
937            s, "{}",
938            "default query should serialize to empty object: {s}"
939        );
940    }
941
942    #[test]
943    fn enum_round_trip_via_json() {
944        // The wire form for each direction/disposition/reason must
945        // match what the daemon and platform expect — this guards
946        // against accidental Rust-side renames.
947        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
948            let s = serde_json::to_string(&d).unwrap();
949            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
950            assert_eq!(d, back);
951        }
952        for d in [
953            VoiceCallDisposition::Answered,
954            VoiceCallDisposition::Missed,
955            VoiceCallDisposition::Rejected,
956            VoiceCallDisposition::Cancelled,
957            VoiceCallDisposition::Failed,
958        ] {
959            let s = serde_json::to_string(&d).unwrap();
960            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
961            assert_eq!(d, back);
962        }
963        for r in [
964            VoiceCallEndReason::HangupLocal,
965            VoiceCallEndReason::HangupRemote,
966            VoiceCallEndReason::RejectedLocal,
967            VoiceCallEndReason::RejectedRemote,
968            VoiceCallEndReason::Missed,
969            VoiceCallEndReason::CancelledLocal,
970            VoiceCallEndReason::TransferredLocal,
971            VoiceCallEndReason::ConnectionLost,
972            VoiceCallEndReason::Failed,
973        ] {
974            let s = serde_json::to_string(&r).unwrap();
975            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
976            assert_eq!(r, back);
977        }
978    }
979
980    #[test]
981    fn connection_lost_pins_its_wire_string() {
982        // The platform's sync endpoint validates end reasons against
983        // an exact string list — a rename here would make every
984        // upload from a session-timer teardown bounce with a 400.
985        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
986        assert_eq!(s, "\"connection_lost\"");
987    }
988
989    #[test]
990    fn transferred_local_pins_its_wire_string() {
991        // Same contract as `connection_lost`: the platform validates
992        // against an exact string list, so a rename here would bounce
993        // every transferred-call upload with a 400.
994        let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
995        assert_eq!(s, "\"transferred_local\"");
996    }
997
998    #[test]
999    fn record_round_trips_transfer_target() {
1000        // A transferred call carries `transferTarget` both ways — the
1001        // daemon ships it (it's its own data, not read-only decoration),
1002        // and the platform echoes it back on read.
1003        let raw = r#"{
1004            "sourceId": "a",
1005            "accountId": "b",
1006            "direction": "inbound",
1007            "party": "Alice <sip:alice@example.com>",
1008            "ringAt": "2026-06-28T10:00:00Z",
1009            "answerAt": "2026-06-28T10:00:05Z",
1010            "endAt": "2026-06-28T10:00:30Z",
1011            "durationMs": 25000,
1012            "disposition": "answered",
1013            "endReason": "transferred_local",
1014            "transferTarget": "1002"
1015        }"#;
1016        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1017        assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1018        assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1019        // And it survives a re-serialize (daemon → platform direction).
1020        let s = serde_json::to_string(&parsed).unwrap();
1021        assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1022    }
1023
1024    #[test]
1025    fn voice_calls_marker_resource_is_calls() {
1026        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1027    }
1028
1029    #[test]
1030    fn record_accepts_unknown_extras_for_forward_compat() {
1031        // A newer client shipping a `notes` field that this platform
1032        // version doesn't have a column for should round-trip via
1033        // the `extras` envelope. The platform persists the blob
1034        // verbatim; a future deploy can promote it to a typed
1035        // column without data loss.
1036        let raw = r#"{
1037            "sourceId": "a",
1038            "accountId": "b",
1039            "direction": "inbound",
1040            "party": "anon",
1041            "ringAt": "2026-05-16T10:00:00Z",
1042            "endAt": "2026-05-16T10:00:30Z",
1043            "disposition": "answered",
1044            "endReason": "hangup_remote",
1045            "schemaVersion": 2,
1046            "extras": { "notes": "from staging build" }
1047        }"#;
1048        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1049        assert_eq!(parsed.envelope.schema_version, Some(2));
1050        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1051        assert_eq!(extras["notes"], "from staging build");
1052    }
1053
1054    #[test]
1055    fn call_record_parses_share_visibility_from_list_response() {
1056        // The list / detail endpoints decorate a call with the tier of any
1057        // active share on its recording, so a consumer can badge the row.
1058        let raw = r#"{
1059            "sourceId": "a",
1060            "accountId": "b",
1061            "direction": "outbound",
1062            "party": "+14155550123",
1063            "ringAt": "2026-05-16T10:00:00Z",
1064            "endAt": "2026-05-16T10:00:30Z",
1065            "disposition": "answered",
1066            "endReason": "hangup_remote",
1067            "shareVisibility": "public"
1068        }"#;
1069        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1070        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1071
1072        let restricted = raw.replace("public", "restricted");
1073        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1074        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1075    }
1076
1077    #[test]
1078    fn call_record_unshared_has_no_share_visibility() {
1079        // Absent (older platform, or an unshared call) and an explicit
1080        // `null` both read as "not shared" — never `Some(Private)`.
1081        let base = r#"{
1082            "sourceId": "a",
1083            "accountId": "b",
1084            "direction": "inbound",
1085            "party": "anon",
1086            "ringAt": "2026-05-16T10:00:00Z",
1087            "endAt": "2026-05-16T10:00:30Z",
1088            "disposition": "missed",
1089            "endReason": "missed"
1090        }"#;
1091        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1092        assert_eq!(parsed.share_visibility, None);
1093
1094        let with_null = base.replace(
1095            r#""endReason": "missed""#,
1096            r#""endReason": "missed", "shareVisibility": null"#,
1097        );
1098        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1099        assert_eq!(parsed.share_visibility, None);
1100    }
1101
1102    #[test]
1103    fn synced_call_omits_share_visibility() {
1104        // `share_visibility` is read-only decoration: a call uploaded via
1105        // sync must not carry it on the wire (skip_serializing_if = None),
1106        // so the round trip from a sync-shaped record stays clean.
1107        let raw = r#"{
1108            "sourceId": "a",
1109            "accountId": "b",
1110            "direction": "inbound",
1111            "party": "anon",
1112            "ringAt": "2026-05-16T10:00:00Z",
1113            "endAt": "2026-05-16T10:00:30Z",
1114            "disposition": "answered",
1115            "endReason": "hangup_remote"
1116        }"#;
1117        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1118        assert_eq!(parsed.share_visibility, None);
1119        let s = serde_json::to_string(&parsed).unwrap();
1120        assert!(
1121            !s.contains("shareVisibility"),
1122            "sync payload leaked share_visibility: {s}"
1123        );
1124    }
1125
1126    #[test]
1127    fn recording_marker_resource_is_recordings() {
1128        // Path constant drives the URL in `Client::sync_recordings`;
1129        // a rename here would silently 404 against the platform.
1130        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1131    }
1132
1133    #[test]
1134    fn recording_record_serializes_with_camel_case_and_envelope() {
1135        let r = VoiceRecordingRecord {
1136            source_id: "11111111-1111-4111-8111-111111111111".into(),
1137            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1138            size_bytes: 44 + 64_000,
1139            duration_ms: 2_000,
1140            sample_rate: 8_000,
1141            channels: 2,
1142            created_at: "2026-05-16T10:01:05Z".into(),
1143            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1144        };
1145        let s = serde_json::to_string(&r).unwrap();
1146        // Field-by-field wire contract — these strings are also what
1147        // the platform's Zod schema expects.
1148        assert!(s.contains("\"sourceId\":"), "{s}");
1149        assert!(s.contains("\"callSourceId\":"), "{s}");
1150        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1151        assert!(s.contains("\"durationMs\":2000"), "{s}");
1152        assert!(s.contains("\"sampleRate\":8000"), "{s}");
1153        assert!(s.contains("\"channels\":2"), "{s}");
1154        assert!(s.contains("\"createdAt\":"), "{s}");
1155        // Envelope flattens to the top of the object, same as VoiceCallRecord.
1156        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1157    }
1158
1159    #[test]
1160    fn recordings_sync_response_round_trips() {
1161        // The richer-than-generic response carries per-item provenance —
1162        // the daemon's uploader reads `r2Key` for the bytes follow-up
1163        // and `bytesUploaded` to short-circuit when the row already
1164        // landed on a previous cycle.
1165        let raw = r#"{
1166            "accepted": 2,
1167            "skipped": 0,
1168            "items": [
1169                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1170                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1171            ]
1172        }"#;
1173        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1174        assert_eq!(parsed.accepted, 2);
1175        assert_eq!(parsed.items.len(), 2);
1176        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1177        assert!(!parsed.items[0].bytes_uploaded);
1178        assert!(parsed.items[1].bytes_uploaded);
1179    }
1180
1181    #[test]
1182    fn install_heartbeat_request_serializes_with_camel_case_keys() {
1183        let req = InstallHeartbeatRequest {
1184            install_id: "11111111-1111-4111-8111-111111111111".into(),
1185            app_version: "0.0.21".into(),
1186            os: "macos".into(),
1187            os_version: Some("15.5.0".into()),
1188            arch: Some("aarch64".into()),
1189            locale: Some("en-NZ".into()),
1190        };
1191        let s = serde_json::to_string(&req).unwrap();
1192        assert!(s.contains("\"installId\":"), "{s}");
1193        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1194        assert!(s.contains("\"os\":\"macos\""), "{s}");
1195        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1196        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1197        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1198    }
1199
1200    #[test]
1201    fn install_heartbeat_request_omits_absent_optional_fields() {
1202        // A host where the OS version / locale probe came up empty
1203        // shouldn't send `null` — keeping the keys out lets the
1204        // platform's Zod `.optional()` accept the body and the column
1205        // stay NULL rather than the string "null".
1206        let req = InstallHeartbeatRequest {
1207            install_id: "x".into(),
1208            app_version: "0.0.21".into(),
1209            os: "linux".into(),
1210            os_version: None,
1211            arch: None,
1212            locale: None,
1213        };
1214        let s = serde_json::to_string(&req).unwrap();
1215        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1216        assert!(!s.contains("arch"), "arch should be omitted: {s}");
1217        assert!(!s.contains("locale"), "locale should be omitted: {s}");
1218    }
1219
1220    #[test]
1221    fn install_heartbeat_response_parses_platform_shape() {
1222        let raw = r#"{
1223            "id": "abc-123",
1224            "installId": "11111111-1111-4111-8111-111111111111",
1225            "appVersion": "0.0.21",
1226            "os": "macos",
1227            "osVersion": "15.5.0",
1228            "arch": "aarch64",
1229            "locale": null,
1230            "firstSeenAt": "2026-05-31T10:00:00.000Z",
1231            "lastSeenAt": "2026-05-31T10:00:00.000Z"
1232        }"#;
1233        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1234        assert_eq!(parsed.id, "abc-123");
1235        assert_eq!(parsed.app_version, "0.0.21");
1236        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1237        assert!(parsed.locale.is_none());
1238    }
1239
1240    #[test]
1241    fn system_info_detect_fills_os_and_arch() {
1242        // os / arch come from compile-time consts, so they're always
1243        // non-empty on every supported target. os_version / locale are
1244        // best-effort and intentionally not asserted.
1245        let sys = SystemInfo::detect();
1246        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1247        assert!(
1248            !sys.arch.is_empty(),
1249            "arch should be a non-empty target string"
1250        );
1251    }
1252
1253    #[test]
1254    fn transcripts_marker_resource_is_transcripts() {
1255        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1256    }
1257
1258    #[test]
1259    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1260        let r = VoiceTranscriptRecord {
1261            source_id: "1".into(),
1262            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1263            channel: VoiceTranscriptChannel::Remote,
1264            ts_ms: 100,
1265            end_ms: 1_500,
1266            text: "hello".into(),
1267            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1268        };
1269        let s = serde_json::to_string(&r).unwrap();
1270        assert!(s.contains("\"sourceId\":"), "{s}");
1271        assert!(s.contains("\"callSourceId\":"), "{s}");
1272        // The channel enum is wire-stable snake_case — matches the
1273        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1274        assert!(s.contains("\"channel\":\"remote\""), "{s}");
1275        assert!(s.contains("\"tsMs\":100"), "{s}");
1276        assert!(s.contains("\"endMs\":1500"), "{s}");
1277        assert!(s.contains("\"text\":\"hello\""), "{s}");
1278        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1279    }
1280
1281    #[test]
1282    fn share_visibility_pins_its_wire_strings() {
1283        // The platform validates these against an exact string list; a
1284        // rename would bounce every share command with a 400.
1285        assert_eq!(
1286            serde_json::to_string(&ShareVisibility::Private).unwrap(),
1287            "\"private\""
1288        );
1289        assert_eq!(
1290            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1291            "\"restricted\""
1292        );
1293        assert_eq!(
1294            serde_json::to_string(&ShareVisibility::Public).unwrap(),
1295            "\"public\""
1296        );
1297        for v in [
1298            ShareVisibility::Private,
1299            ShareVisibility::Restricted,
1300            ShareVisibility::Public,
1301        ] {
1302            let s = serde_json::to_string(&v).unwrap();
1303            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1304            assert_eq!(v, back);
1305        }
1306    }
1307
1308    #[test]
1309    fn share_request_serializes_with_camel_case_and_omits_unset() {
1310        let req = ShareRecordingRequest {
1311            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1312            visibility: ShareVisibility::Public,
1313            invited_emails: None,
1314            party_masking: None,
1315            show_transcript: None,
1316            show_audio: None,
1317            allow_download: None,
1318            default_mute_local: None,
1319            default_mute_remote: None,
1320            password: None,
1321            expires_at: None,
1322        };
1323        let s = serde_json::to_string(&req).unwrap();
1324        assert!(s.contains("\"recordingSourceId\":"), "{s}");
1325        assert!(s.contains("\"visibility\":\"public\""), "{s}");
1326        // Phase-2 / tier-specific / visibility-control fields stay off the
1327        // wire when unset so the platform's `.optional()` schema accepts the
1328        // body (and the omitted controls fall to the platform defaults).
1329        assert!(!s.contains("invitedEmails"), "{s}");
1330        assert!(!s.contains("partyMasking"), "{s}");
1331        assert!(!s.contains("showTranscript"), "{s}");
1332        assert!(!s.contains("showAudio"), "{s}");
1333        assert!(!s.contains("allowDownload"), "{s}");
1334        assert!(!s.contains("defaultMuteLocal"), "{s}");
1335        assert!(!s.contains("defaultMuteRemote"), "{s}");
1336        assert!(!s.contains("password"), "{s}");
1337        assert!(!s.contains("expiresAt"), "{s}");
1338    }
1339
1340    #[test]
1341    fn share_request_serializes_visibility_controls_camel_case() {
1342        let req = ShareRecordingRequest {
1343            recording_source_id: "a".into(),
1344            visibility: ShareVisibility::Public,
1345            invited_emails: None,
1346            party_masking: Some(PartyMasking::Partial),
1347            show_transcript: Some(false),
1348            show_audio: Some(true),
1349            allow_download: Some(true),
1350            default_mute_local: Some(false),
1351            default_mute_remote: Some(true),
1352            password: None,
1353            expires_at: None,
1354        };
1355        let s = serde_json::to_string(&req).unwrap();
1356        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1357        assert!(s.contains("\"showTranscript\":false"), "{s}");
1358        assert!(s.contains("\"showAudio\":true"), "{s}");
1359        assert!(s.contains("\"allowDownload\":true"), "{s}");
1360        // The owner muted their own side by default but left the other
1361        // party audible — both ride the wire as camelCase booleans.
1362        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1363        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1364    }
1365
1366    #[test]
1367    fn share_request_carries_invited_emails_for_restricted() {
1368        let req = ShareRecordingRequest {
1369            recording_source_id: "a".into(),
1370            visibility: ShareVisibility::Restricted,
1371            invited_emails: Some(vec!["alex@example.com".into()]),
1372            party_masking: None,
1373            show_transcript: None,
1374            show_audio: None,
1375            allow_download: None,
1376            default_mute_local: None,
1377            default_mute_remote: None,
1378            password: None,
1379            expires_at: None,
1380        };
1381        let s = serde_json::to_string(&req).unwrap();
1382        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1383        assert!(
1384            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1385            "{s}"
1386        );
1387    }
1388
1389    #[test]
1390    fn share_response_parses_platform_shape() {
1391        let raw = r#"{
1392            "visibility": "public",
1393            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1394            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1395            "sharedAt": "2026-06-19T10:00:00.000Z"
1396        }"#;
1397        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1398        assert_eq!(parsed.visibility, ShareVisibility::Public);
1399        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1400        assert!(parsed.share_url.ends_with(&parsed.token));
1401    }
1402
1403    #[test]
1404    fn share_state_parses_restricted_with_invited_emails() {
1405        // The GET read carries the audience back — this is the field the
1406        // POST reply omits and the desktop "who can open this" panel needs.
1407        let raw = r#"{
1408            "visibility": "restricted",
1409            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1410            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1411            "sharedAt": "2026-06-19T10:00:00.000Z",
1412            "invitedEmails": ["bob@example.com", "carol@example.com"],
1413            "partyMasking": "full",
1414            "showTranscript": true,
1415            "showAudio": false,
1416            "allowDownload": false,
1417            "defaultMuteLocal": false,
1418            "defaultMuteRemote": true
1419        }"#;
1420        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1421        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1422        assert_eq!(
1423            parsed.invited_emails.as_deref(),
1424            Some(
1425                [
1426                    "bob@example.com".to_string(),
1427                    "carol@example.com".to_string()
1428                ]
1429                .as_slice()
1430            )
1431        );
1432        // The visibility controls ride back on the live-share read.
1433        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1434        assert_eq!(parsed.show_transcript, Some(true));
1435        assert_eq!(parsed.show_audio, Some(false));
1436        // Audio hidden here, so download comes back off (platform folds the two).
1437        assert_eq!(parsed.allow_download, Some(false));
1438        // Per-channel playback defaults ride back too.
1439        assert_eq!(parsed.default_mute_local, Some(false));
1440        assert_eq!(parsed.default_mute_remote, Some(true));
1441    }
1442
1443    #[test]
1444    fn share_state_parses_private_with_fields_absent() {
1445        // A never-shared (or revoked) recording reports private with no
1446        // token / url / emails — the optional fields stay None.
1447        let parsed: ShareStateResponse =
1448            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1449        assert_eq!(parsed.visibility, ShareVisibility::Private);
1450        assert!(parsed.token.is_none());
1451        assert!(parsed.share_url.is_none());
1452        assert!(parsed.shared_at.is_none());
1453        assert!(parsed.invited_emails.is_none());
1454    }
1455
1456    #[test]
1457    fn share_request_rejects_empty_source_id_before_hitting_network() {
1458        // Guarded client-side so an empty id can't produce a path like
1459        // `/api/voice/recordings//share` that 404s confusingly.
1460        let req = ShareRecordingRequest {
1461            recording_source_id: String::new(),
1462            visibility: ShareVisibility::Private,
1463            invited_emails: None,
1464            party_masking: None,
1465            show_transcript: None,
1466            show_audio: None,
1467            allow_download: None,
1468            default_mute_local: None,
1469            default_mute_remote: None,
1470            password: None,
1471            expires_at: None,
1472        };
1473        // We can't call the async method without a runtime here, but the
1474        // guard mirrors `upload_recording_bytes` — assert the precondition
1475        // shape the method checks.
1476        assert!(req.recording_source_id.is_empty());
1477    }
1478
1479    // ---- VoiceAccounts ----
1480
1481    fn sample_account() -> VoiceAccountRecord {
1482        VoiceAccountRecord {
1483            source_id: "11111111-1111-4111-8111-111111111111".into(),
1484            enabled: true,
1485            display_name: "Work line".into(),
1486            username: "alice".into(),
1487            domain: "sip.example.com".into(),
1488            auth_username: Some("alice-auth".into()),
1489            server: Some("sip.example.com".into()),
1490            port: Some(5060),
1491            transport: VoiceTransport::Udp,
1492            register_expires: 60,
1493            keepalive_secs: Some(50),
1494            disclosure_enabled: true,
1495            updated_at: "2026-06-20T10:00:00Z".into(),
1496            deleted_at: None,
1497            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1498        }
1499    }
1500
1501    #[test]
1502    fn accounts_marker_resource_is_accounts() {
1503        // Path constant drives the URL in `Client::sync` / `Client::list`;
1504        // a rename here would silently 404 against the platform.
1505        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1506    }
1507
1508    #[test]
1509    fn account_record_serializes_with_camel_case_and_envelope() {
1510        let s = serde_json::to_string(&sample_account()).unwrap();
1511        // Field-by-field wire contract — also what the platform's Zod
1512        // schema expects.
1513        assert!(s.contains("\"sourceId\":"), "{s}");
1514        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1515        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1516        assert!(s.contains("\"registerExpires\":60"), "{s}");
1517        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1518        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1519        assert!(s.contains("\"transport\":\"udp\""), "{s}");
1520        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1521        // A live line carries no tombstone.
1522        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1523        // The secret never crosses this wire, by construction.
1524        assert!(!s.contains("password"), "no password field: {s}");
1525        // Envelope flattens to the top, same as the other resources.
1526        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1527    }
1528
1529    #[test]
1530    fn account_tombstone_serializes_deleted_at() {
1531        // A soft-delete rides as an upsert with deletedAt set — the
1532        // delete-propagation mechanism (doc 40).
1533        let mut r = sample_account();
1534        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1535        let s = serde_json::to_string(&r).unwrap();
1536        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1537    }
1538
1539    #[test]
1540    fn account_record_round_trips_optional_fields() {
1541        // A minimal line — no auth username, server, port, keepalive, or
1542        // tombstone — should parse with those all absent.
1543        let raw = r#"{
1544            "sourceId": "a",
1545            "enabled": false,
1546            "displayName": "Cheap trunk",
1547            "username": "u",
1548            "domain": "d",
1549            "transport": "tcp",
1550            "registerExpires": 120,
1551            "disclosureEnabled": false,
1552            "updatedAt": "2026-06-20T10:00:00Z"
1553        }"#;
1554        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1555        assert!(!parsed.enabled);
1556        assert!(parsed.auth_username.is_none());
1557        assert!(parsed.server.is_none());
1558        assert!(parsed.port.is_none());
1559        assert!(parsed.keepalive_secs.is_none());
1560        assert!(parsed.deleted_at.is_none());
1561        assert_eq!(parsed.transport, VoiceTransport::Tcp);
1562        assert_eq!(parsed.register_expires, 120);
1563    }
1564
1565    #[test]
1566    fn voice_transport_round_trips_via_json() {
1567        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1568            let s = serde_json::to_string(&t).unwrap();
1569            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1570            assert_eq!(t, back);
1571        }
1572        // Pin the wire strings — the daemon's `TransportKind` and the
1573        // platform's Zod enum both depend on these exact tokens.
1574        assert_eq!(
1575            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1576            "\"udp\""
1577        );
1578        assert_eq!(
1579            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1580            "\"tcp\""
1581        );
1582    }
1583
1584    #[test]
1585    fn accounts_query_omits_unset_and_serializes_include_deleted() {
1586        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1587        assert_eq!(empty, "{}", "default query should be empty: {empty}");
1588        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1589            include_deleted: Some(true),
1590        })
1591        .unwrap();
1592        assert!(
1593            with_deleted.contains("\"includeDeleted\":true"),
1594            "{with_deleted}"
1595        );
1596    }
1597
1598    #[test]
1599    fn account_record_accepts_unknown_extras_for_forward_compat() {
1600        // A newer client shipping a field this platform version lacks a
1601        // column for round-trips via the `extras` envelope.
1602        let raw = r#"{
1603            "sourceId": "a",
1604            "enabled": true,
1605            "displayName": "x",
1606            "username": "u",
1607            "domain": "d",
1608            "transport": "udp",
1609            "registerExpires": 60,
1610            "disclosureEnabled": true,
1611            "updatedAt": "2026-06-20T10:00:00Z",
1612            "schemaVersion": 2,
1613            "extras": { "ringtone": "classic" }
1614        }"#;
1615        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1616        assert_eq!(parsed.envelope.schema_version, Some(2));
1617        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1618        assert_eq!(extras["ringtone"], "classic");
1619    }
1620}