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/// The audio codec a call negotiated, stamped once audio flows. Wire-
77/// stable snake_case strings matching the daemon's `CallCodec` enum —
78/// the platform validates against this exact list, so a rename here
79/// would bounce every upload with a 400. New codecs (e.g. `ilbc`) are
80/// wire additions, not replacements.
81///
82/// Consumers render this as a quality tier ("HD" for Opus, "Standard"
83/// for the G.711 pair), not the codec name alone — see the desktop
84/// client's call-details page for the canonical presentation.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum VoiceCallCodec {
88    /// Opus wideband (16 kHz) — the "HD" tier.
89    Opus,
90    /// G.711 µ-law — the narrowband "Standard" tier.
91    Pcmu,
92    /// G.711 A-law — the narrowband "Standard" tier.
93    Pcma,
94}
95
96/// How a call flow's ("receptionist") run ended, folded by the daemon
97/// from the run's terminal trace step. Wire-stable snake_case strings
98/// matching `wavekat_flow::trace::FlowOutcome` — declared here rather
99/// than re-exported so this crate stays free of a `wavekat-flow`
100/// dependency; the two lists must be kept in step.
101///
102/// Consumers prefer this over [`VoiceCallEndReason`] when rendering a
103/// flow-answered call's outcome: the flow's own goodbye sends the BYE,
104/// so the SIP-level reason reads `HangupLocal` ("you hung up") for a
105/// call the user never touched.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum VoiceCallFlowOutcome {
109    /// A `ring` node was answered by a human; the engine stepped out.
110    Answered,
111    /// A `message` node recorded a voicemail.
112    MessageLeft,
113    /// A `transfer` node handed the call to an external number.
114    Transferred,
115    /// A `hangup` node ended the call.
116    HungUp,
117    /// An effect failed mid-run (the call likely dropped).
118    Aborted,
119    /// The flow reached an impossible state. Validation is meant to
120    /// prevent this, so it signals a defect worth alerting on.
121    Defect,
122}
123
124/// One step of a call flow's run, as the daemon projects it from its
125/// local `call_flow_step` events.
126///
127/// Deliberately structural rather than a rendered sentence. The daemon
128/// has an English summary for each step, but the platform's web UI
129/// serves nine locales — shipping prose would make these permanently
130/// untranslatable there. Consumers get the parts and compose the
131/// sentence themselves.
132///
133/// `kind` is a plain `String`, not an enum, and that is the point: step
134/// kinds grow every time the flow engine gains a node type, and a
135/// consumer built against an older version of this crate must still be
136/// able to deserialize a newer daemon's trace. An unknown kind is
137/// rendered as an unnamed marker rather than rejected.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct VoiceCallFlowStep {
141    /// Milliseconds from the call's answer time — the same zero the
142    /// recording starts at, so a step lines up with the audio.
143    pub at_ms: i64,
144    /// The engine's step tag: `spoke`, `hours`, `menu_choice`,
145    /// `menu_no_input`, `menu_invalid`, `ring`, `message_recorded`,
146    /// `transferred`, `hung_up`, or the synthetic `answered` marking a
147    /// mid-run take-over by the owner.
148    pub kind: String,
149    /// The flow node this step belongs to, when it names one.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub node: Option<String>,
152    /// The key the caller pressed — `menu_choice` only.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub digit: Option<String>,
155    /// Recorded message length in seconds — `message_recorded` only.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub secs: Option<i64>,
158    /// Where the call was sent — `transferred` only.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub target: Option<String>,
161    /// Whether an hours check landed inside business hours.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub open: Option<bool>,
164    /// Whether a `ring` step was picked up.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub answered: Option<bool>,
167}
168
169/// One historical call as it crosses the wire from the daemon up to the
170/// platform.
171///
172/// Mirrors the daemon's local `CallRecord` (see
173/// `wavekat-voice/crates/wavekat-voice/src/db.rs`) with one rename:
174/// the daemon's local primary key (`id`) is shipped as `source_id`
175/// because the platform allocates its own row id and treats the
176/// daemon-side UUID as the idempotency key.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct VoiceCallRecord {
180    /// Daemon-generated UUID. The platform's `(user_id, source_id)`
181    /// upsert key — re-syncing the same id is a no-op.
182    pub source_id: String,
183    /// SIP account UUID on the daemon side. Opaque to the platform.
184    pub account_id: String,
185    pub direction: VoiceCallDirection,
186    /// SIP `From:` (inbound) or `To:` (outbound). Free text — caller
187    /// IDs, display names, and SIP URIs all land here.
188    pub party: String,
189    /// RFC 3339. First ring (inbound) or first dial-out (outbound).
190    pub ring_at: String,
191    /// RFC 3339. Present only when the call reached the answered
192    /// state.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub answer_at: Option<String>,
195    /// RFC 3339. Terminal timestamp; the platform uses this as the
196    /// list cursor.
197    pub end_at: String,
198    /// `answer_at` → `end_at` in milliseconds. `None` for calls that
199    /// were never answered.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub duration_ms: Option<i64>,
202    pub disposition: VoiceCallDisposition,
203    pub end_reason: VoiceCallEndReason,
204    /// Free-text error, populated only when `disposition == Failed`.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub error: Option<String>,
207    /// Visibility tier of any *active* (not revoked / expired) share on this
208    /// call's recording, or `None` when it isn't shared. Read-only: the
209    /// platform sets it on list (`GET /api/voice/calls`) and detail responses
210    /// so a consumer can badge the row "Public" / "Invited only"; it is
211    /// skipped on serialize, so syncing a call never sends it. `Private` never
212    /// appears here — an unshared call is `None`.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub share_visibility: Option<ShareVisibility>,
215    /// Where a transferred call was sent — the number or SIP address the
216    /// far end was asked to call (RFC 3515 `Refer-To`). Set only when
217    /// `end_reason == TransferredLocal`; `None` for every other call.
218    /// Unlike `share_visibility` this is daemon-owned data, so it *is*
219    /// sent on sync (serialized when present) and echoed back on read.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub transfer_target: Option<String>,
222    /// The negotiated audio codec, present when the call reached the
223    /// audio-flowing state on a daemon new enough to record it; `None`
224    /// for never-answered calls and rows synced by older daemons. Like
225    /// `transfer_target` this is daemon-owned data, so it *is* sent on
226    /// sync (serialized when present) and echoed back on read.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub codec: Option<VoiceCallCodec>,
229    /// Which call flow ("receptionist") answered this call, when one
230    /// did: the platform flow id the daemon held at answer time, and
231    /// the flow's display name *at that moment*. The name is shipped
232    /// verbatim rather than resolved from the flow on read, so a later
233    /// rename or delete doesn't rewrite what history says happened.
234    /// Both `None` for calls the user answered themselves. Daemon-owned
235    /// data like `codec`, so both are sent on sync and echoed on read.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub flow_id: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub flow_name: Option<String>,
240    /// How the flow's run ended. `None` when no flow answered, and for
241    /// runs with no terminal step (the caller hung up mid-flow) — there
242    /// [`VoiceCallRecord::end_reason`] is already the honest story.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub flow_outcome: Option<VoiceCallFlowOutcome>,
245    /// The flow run's step-by-step trace, in answer-time order. Drives
246    /// the markers the platform's call-detail page draws on the
247    /// recording waveform.
248    ///
249    /// `None` for human-answered calls and for daemons predating the
250    /// trace. Sent on sync like the other daemon-owned fields, but
251    /// echoed back only on the *detail* read (`GET /api/voice/calls/
252    /// {sourceId}`) — the list route omits it, since nothing on a list
253    /// row renders a trace and it would weigh down every page.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub flow_steps: Option<Vec<VoiceCallFlowStep>>,
256    /// Version + forward-compat fields shared by every sync record.
257    /// Flattened so `schemaVersion` and `extras` sit at the top of
258    /// the JSON object alongside the other columns. See
259    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
260    /// compatibility".
261    #[serde(flatten, default)]
262    pub envelope: SyncEnvelope,
263}
264
265/// Query params for `GET /api/voice/calls`. All fields optional — the
266/// default returns the newest page.
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub struct VoiceCallsQuery {
270    /// RFC 3339 cursor; rows with `end_at < before` are returned.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub before: Option<String>,
273    /// 1..=200. Server default is 50.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub limit: Option<u32>,
276}
277
278/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
279///
280/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
281pub struct VoiceCalls;
282
283impl SyncEndpoint for VoiceCalls {
284    const RESOURCE: &'static str = "calls";
285    type Record = VoiceCallRecord;
286    type Query = VoiceCallsQuery;
287}
288
289impl HasSyncEnvelope for VoiceCallRecord {
290    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
291        &mut self.envelope
292    }
293}
294
295// ---- VoiceRecordings ------------------------------------------------------
296
297/// One per-call recording's metadata as it crosses the wire from the
298/// daemon up to the platform. The WAV bytes ride on a separate
299/// follow-up call ([`Client::upload_recording_bytes`]) so the
300/// idempotent metadata sync stays small and a flaky bytes upload
301/// doesn't force the daemon to re-ship the row.
302///
303/// Mirrors the daemon's `RecordingArtifact` (see
304/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
305/// rename: the daemon's local id (`id`) ships as `source_id` because
306/// the platform allocates its own row id and treats the daemon-side
307/// UUID as the idempotency key (same convention as
308/// [`VoiceCallRecord`]).
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct VoiceRecordingRecord {
312    /// Daemon-generated UUID for this recording artifact. Upsert key
313    /// on the platform side.
314    pub source_id: String,
315    /// Daemon's `calls.id` — the call this recording belongs to.
316    /// The platform stores both so the /voice/calls history page can
317    /// link a call to its recording without a separate join table.
318    pub call_source_id: String,
319    /// Byte length of the WAV file the daemon will PUT in the follow-
320    /// up bytes call. The platform refuses a PUT whose body length
321    /// disagrees.
322    pub size_bytes: u64,
323    pub duration_ms: u64,
324    pub sample_rate: u32,
325    pub channels: u16,
326    /// RFC 3339 timestamp the daemon stamped on the artifact at
327    /// finalize time. Drives the platform's `/voice/recordings` GET
328    /// cursor.
329    pub created_at: String,
330    #[serde(flatten, default)]
331    pub envelope: SyncEnvelope,
332}
333
334/// Query params for `GET /api/voice/recordings`.
335#[derive(Debug, Clone, Default, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct VoiceRecordingsQuery {
338    /// RFC 3339 cursor; rows with `created_at < before` are returned.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub before: Option<String>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub limit: Option<u32>,
343}
344
345/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
346///
347/// The corresponding bytes-upload endpoint
348/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
349/// [`Client::upload_recording_bytes`] — it doesn't fit the
350/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
351/// inherent method on `Client`.
352pub struct VoiceRecordings;
353
354impl SyncEndpoint for VoiceRecordings {
355    const RESOURCE: &'static str = "recordings";
356    type Record = VoiceRecordingRecord;
357    type Query = VoiceRecordingsQuery;
358}
359
360impl HasSyncEnvelope for VoiceRecordingRecord {
361    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
362        &mut self.envelope
363    }
364}
365
366/// One item in the platform's response to
367/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
368/// key the platform stamped (so a subsequent bytes PUT can target it)
369/// without re-deriving it, and check whether bytes have already
370/// landed on a prior cycle (so the daemon can mark the local row
371/// synced without re-uploading the WAV).
372#[derive(Debug, Clone, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct VoiceRecordingSyncItem {
375    pub source_id: String,
376    pub r2_key: String,
377    pub bytes_uploaded: bool,
378}
379
380/// Full response from `POST /api/voice/recordings/sync`. Superset of
381/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct VoiceRecordingsSyncResponse {
385    pub accepted: u32,
386    pub skipped: u32,
387    pub items: Vec<VoiceRecordingSyncItem>,
388}
389
390// ---- VoiceTranscripts -----------------------------------------------------
391
392/// Wire-stable transcript channel tag. Matches the daemon's
393/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(rename_all = "snake_case")]
396pub enum VoiceTranscriptChannel {
397    /// Local mic audio — what the user said.
398    Local,
399    /// Received RTP audio — what the remote party said.
400    Remote,
401}
402
403/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
404/// crosses the wire. Each segment is a row on the daemon side
405/// (`transcripts` table); the daemon batches a slice of them per
406/// upload and the platform upserts per (user_id, source_id).
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(rename_all = "camelCase")]
409pub struct VoiceTranscriptRecord {
410    /// Daemon-side row id, formatted as text (the column is an
411    /// autoincrement integer on SQLite). Stable per (call, segment)
412    /// so re-shipping converges.
413    pub source_id: String,
414    /// Daemon's `calls.id` — the call this segment belongs to.
415    pub call_source_id: String,
416    pub channel: VoiceTranscriptChannel,
417    /// Start of the segment in milliseconds relative to the start of
418    /// the call's audio stream (not wall-clock).
419    pub ts_ms: i64,
420    /// End of the segment, same reference frame as `ts_ms`.
421    pub end_ms: i64,
422    /// Recognised text. Free-form; the platform stores it verbatim.
423    pub text: String,
424    #[serde(flatten, default)]
425    pub envelope: SyncEnvelope,
426}
427
428/// Query params for `GET /api/voice/transcripts` — required
429/// `call_source_id` (the endpoint refuses a flat list).
430#[derive(Debug, Clone, Default, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct VoiceTranscriptsQuery {
433    pub call_source_id: String,
434}
435
436/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
437pub struct VoiceTranscripts;
438
439impl SyncEndpoint for VoiceTranscripts {
440    const RESOURCE: &'static str = "transcripts";
441    type Record = VoiceTranscriptRecord;
442    type Query = VoiceTranscriptsQuery;
443}
444
445impl HasSyncEnvelope for VoiceTranscriptRecord {
446    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
447        &mut self.envelope
448    }
449}
450
451// ---- VoiceAccounts --------------------------------------------------------
452
453/// SIP transport for a synced account line. Wire-stable snake_case;
454/// mirrors the daemon's `TransportKind`.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "snake_case")]
457pub enum VoiceTransport {
458    Udp,
459    Tcp,
460}
461
462/// One SIP account line's *configuration* as it crosses the wire from a
463/// device up to the platform and back down to another device
464/// (`wavekat-voice/docs/40-account-config-sync.md`).
465///
466/// Unlike calls / recordings / transcripts — which are immutable,
467/// one-way pushes — account config is **mutable and bidirectional**: a
468/// line is edited, toggled, renamed, and deleted, and those changes must
469/// restore onto a second device. The same idempotent
470/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
471/// every kind of change here; a *delete* is a soft-delete that rides as
472/// an upsert with `deleted_at` set, because a hard DELETE can't sync
473/// under a "push the row" model — once the row is gone there's nothing
474/// left to push.
475///
476/// **No secret field, by construction.** The SIP password never appears
477/// on this wire. Config sync (policy levels 1–2) keeps the credential
478/// device-local, and the end-to-end-encrypted secret path (level 3)
479/// ships its ciphertext through a *separate* opaque resource, never as a
480/// field here. Omitting it means level 3 can't be populated by accident
481/// before it exists.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct VoiceAccountRecord {
485    /// Daemon-side account UUID (`accounts.id`). The platform's
486    /// `(user_id, source_id)` upsert key — re-syncing the same id
487    /// updates the row in place (mutable), unlike the immutable
488    /// resources where a re-sync is a no-op.
489    pub source_id: String,
490    /// Whether the line registers on daemon boot. Pausing a line is a
491    /// portable preference, so it rides along.
492    pub enabled: bool,
493    pub display_name: String,
494    pub username: String,
495    pub domain: String,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub auth_username: Option<String>,
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub server: Option<String>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub port: Option<u16>,
502    pub transport: VoiceTransport,
503    pub register_expires: u32,
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub keepalive_secs: Option<u32>,
506    /// Record-disclosure beep toggle — a column on the account row, so
507    /// it rides along for free (the account-portable taxonomy in doc 40).
508    pub disclosure_enabled: bool,
509    /// RFC 3339 last-modification time — the **last-write-wins key**. On
510    /// conflict the platform (and a pulling client) keep the copy with
511    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
512    /// deferred until users actually report lost edits (doc 40).
513    pub updated_at: String,
514    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
515    /// was deleted on some device at that time. A tombstone syncs like
516    /// any other mutation so the delete propagates to other devices,
517    /// then is reaped locally once confirmed. The platform retains
518    /// tombstones so a late-syncing device still learns about the delete.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub deleted_at: Option<String>,
521    /// Version + forward-compat fields shared by every sync record.
522    #[serde(flatten, default)]
523    pub envelope: SyncEnvelope,
524}
525
526/// Query params for `GET /api/voice/accounts`. All fields optional.
527#[derive(Debug, Clone, Default, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct VoiceAccountsQuery {
530    /// Include soft-deleted tombstones in the response. Absent / false
531    /// returns only live lines — the restore-grade pull a fresh device
532    /// wants. A delta-syncing device sets this `true` to also learn
533    /// about deletes made elsewhere (doc 40).
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub include_deleted: Option<bool>,
536}
537
538/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
539///
540/// Accounts are the first *mutable, bidirectional* sync resource, but
541/// the wire shape is the same idempotent upsert the immutable resources
542/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
543/// was reserved for exactly this case — so no new HTTP plumbing is
544/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
545/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
546pub struct VoiceAccounts;
547
548impl SyncEndpoint for VoiceAccounts {
549    const RESOURCE: &'static str = "accounts";
550    type Record = VoiceAccountRecord;
551    type Query = VoiceAccountsQuery;
552}
553
554impl HasSyncEnvelope for VoiceAccountRecord {
555    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
556        &mut self.envelope
557    }
558}
559
560// ---- VoiceFlows (published pull) -------------------------------------------
561//
562// The daemon-facing pull leg of the call-flow ("Receptionist") system —
563// `wavekat-voice/docs/48-ivr-call-flows.md`'s control-plane split. Flows
564// are *authored* on the platform (drafts, publish gate, version
565// history); the daemon only ever reads the published snapshots, caches
566// them locally, and runs them offline. There is no upload direction, so
567// this is not a `SyncEndpoint` (that trait models the `{resource}/sync`
568// + list pair): it's a single typed GET, like the share commands above.
569
570/// One published call-flow snapshot as served by
571/// `GET /api/voice/flows/published`: the latest published version of a
572/// flow the bearer authored. The YAML carries the platform-stamped
573/// `id`/`name`/`version` and is served verbatim — the daemon re-parses
574/// and re-validates it on load rather than trusting the wire.
575#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576#[serde(rename_all = "camelCase")]
577pub struct VoiceFlowRecord {
578    /// Platform-assigned flow id (`flow_…`), stable across versions.
579    pub id: String,
580    pub name: String,
581    /// Latest published version number (1-based, bumps on publish).
582    pub version: u32,
583    /// The immutable published document, verbatim.
584    pub yaml: String,
585    /// RFC 3339 time this version was published.
586    pub published_at: String,
587}
588
589/// Query params for `GET /api/voice/flows/published`. Cursor-paginated
590/// by flow id ascending; pass the previous page's `next_after` until it
591/// comes back `None` to collect the full set. The full set is what the
592/// daemon's reconcile wants — a cached flow absent from a complete pull
593/// was deleted on the platform.
594#[derive(Debug, Clone, Default, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct VoiceFlowsQuery {
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub after: Option<String>,
599    /// Page size, server-capped at 100. `None` = server default (50).
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub limit: Option<u32>,
602}
603
604/// One page of published flow snapshots.
605#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
606#[serde(rename_all = "camelCase")]
607pub struct VoiceFlowsPage {
608    pub items: Vec<VoiceFlowRecord>,
609    /// Cursor for the next page; `None` = end of the set.
610    #[serde(default)]
611    pub next_after: Option<String>,
612}
613
614/// One frozen audio asset of a published flow version, as served by
615/// `GET /api/voice/flows/{id}/versions/{version}/assets` (wavekat-platform
616/// docs 16/17). The bytes were copied into a version-owned R2 object at
617/// publish time and never change, so `content_hash` identifies them
618/// exactly — the daemon diffs its local cache against it rather than
619/// trusting a bare filename, because the *same* `ref` can carry different
620/// bytes across two versions of the same flow.
621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
622#[serde(rename_all = "camelCase")]
623pub struct VoiceFlowVersionAsset {
624    /// The `vprompt_…` reference exactly as it appears in the flow YAML.
625    #[serde(rename = "ref")]
626    pub asset_ref: String,
627    /// Source telephony format the clip was frozen as (`ulaw_8000`,
628    /// `pcm_16000`, `mp3`, …); the container is WAV unless `mp3`.
629    pub format: String,
630    /// Size of the frozen bytes.
631    pub byte_size: u64,
632    /// Clip duration if the platform knew it at freeze time.
633    #[serde(default)]
634    pub duration_ms: Option<u64>,
635    /// sha256 of the frozen bytes — the cache's content key.
636    pub content_hash: String,
637}
638
639/// The frozen-asset manifest for one published version. Not paginated:
640/// a flow's asset count is bounded by its node count (a phone tree is
641/// tens of clips), so the platform returns them all in one response.
642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
643#[serde(rename_all = "camelCase")]
644pub struct VoiceFlowAssetsPage {
645    pub assets: Vec<VoiceFlowVersionAsset>,
646}
647
648impl Client {
649    /// `GET /api/voice/flows/published` — one page of the caller's
650    /// published flow snapshots (latest version each). Strictly
651    /// creator-scoped server-side; never returns another user's flows.
652    pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
653        self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
654            .await
655    }
656
657    /// `GET /api/voice/flows/{id}/versions/{version}/assets` — the frozen
658    /// audio manifest for one published version (docs 16/17). Flow-scoped
659    /// server-side: a version of a flow the caller doesn't own is a 404,
660    /// never another user's assets. An existing, visible version with no
661    /// generated audio returns an empty manifest.
662    pub async fn flow_version_assets(
663        &self,
664        flow_id: &str,
665        version: u32,
666    ) -> Result<VoiceFlowAssetsPage> {
667        let path = format!("/api/voice/flows/{flow_id}/versions/{version}/assets");
668        self.get_json::<VoiceFlowAssetsPage>(&path).await
669    }
670
671    /// `GET /api/voice/flows/{id}/versions/{version}/assets/{ref}/bytes` —
672    /// the immutable frozen copy of one clip, served from the version's own
673    /// asset set (never the mutable library). Returned in memory because a
674    /// clip is tens of KB and the daemon writes it atomically into its
675    /// on-disk cache; same flow-scoped 404 as the manifest.
676    pub async fn flow_version_asset_bytes(
677        &self,
678        flow_id: &str,
679        version: u32,
680        asset_ref: &str,
681    ) -> Result<Vec<u8>> {
682        let path =
683            format!("/api/voice/flows/{flow_id}/versions/{version}/assets/{asset_ref}/bytes");
684        self.get_bytes(&path).await
685    }
686}
687
688// ---- Anonymous install heartbeat ------------------------------------------
689//
690// A first-run / per-launch ping the desktop daemon fires *before* (and
691// independently of) any platform sign-in, so the platform can count
692// installs and track version / OS adoption for users who never sign in.
693// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
694// and upserts a row keyed by `install_id` alone (no user) — distinct
695// from the authenticated `voice_clients` heartbeat, which is keyed by
696// `(user, install_id)`.
697//
698// The environment fields (os / os_version / arch / locale) are gathered
699// *here*, inside the client crate, rather than on the consumer side:
700// the daemon only owns the two values this crate genuinely cannot
701// discover — the persisted `install_id` and its own app version.
702
703/// Best-effort snapshot of the host environment, detected at call time.
704/// Every field is best-effort; a probe that fails contributes `None`
705/// (or, for the always-available `os` / `arch`, the compile-time
706/// target) rather than failing the heartbeat.
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct SystemInfo {
709    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
710    pub os: String,
711    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
712    /// can't determine it.
713    pub os_version: Option<String>,
714    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
715    pub arch: String,
716    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
717    /// undetectable (common for GUI-launched apps on some platforms).
718    pub locale: Option<String>,
719}
720
721impl SystemInfo {
722    /// Probe the current host. Cheap enough to call per heartbeat; we
723    /// don't cache so a locale change between launches is reflected.
724    pub fn detect() -> Self {
725        let os_version = match os_info::get().version() {
726            os_info::Version::Unknown => None,
727            v => Some(v.to_string()),
728        };
729        SystemInfo {
730            os: std::env::consts::OS.to_string(),
731            os_version,
732            arch: std::env::consts::ARCH.to_string(),
733            locale: sys_locale::get_locale(),
734        }
735    }
736}
737
738/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
739/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
740/// environment fields from [`SystemInfo::detect`].
741#[derive(Debug, Clone, Serialize, Deserialize)]
742#[serde(rename_all = "camelCase")]
743pub struct InstallHeartbeatRequest {
744    /// The daemon's persisted install UUID — the platform's upsert key.
745    pub install_id: String,
746    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
747    /// daemon side) — *not* this crate's version.
748    pub app_version: String,
749    pub os: String,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub os_version: Option<String>,
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub arch: Option<String>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub locale: Option<String>,
756}
757
758/// The platform's view of an install row, echoed back from a heartbeat.
759#[derive(Debug, Clone, Serialize, Deserialize)]
760#[serde(rename_all = "camelCase")]
761pub struct InstallHeartbeatResponse {
762    pub id: String,
763    pub install_id: String,
764    pub app_version: String,
765    pub os: String,
766    pub os_version: Option<String>,
767    pub arch: Option<String>,
768    pub locale: Option<String>,
769    pub first_seen_at: String,
770    pub last_seen_at: String,
771}
772
773impl Client {
774    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
775    /// first-run install ping. Detects the host environment internally
776    /// and posts it alongside the caller-supplied `install_id` +
777    /// `app_version`. Associated (not a method) because the endpoint is
778    /// unauthenticated — there's no token, and at first run there's no
779    /// signed-in `Client` to hang it off of.
780    ///
781    /// Though unauthenticated, the request is **signed** with the release
782    /// credential `cred` (a per-version Ed25519 key + master-issued
783    /// certificate the consumer bakes in at build time) so the platform
784    /// can verify it came from a genuine release and reject forged or
785    /// replayed pings — see [`Client::post_public_signed_json`] and
786    /// [`crate::sign`]. The platform needs only the master *public* key to
787    /// verify.
788    ///
789    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
790    pub async fn install_heartbeat(
791        base_url: &str,
792        install_id: &str,
793        app_version: &str,
794        cred: &ReleaseCredential,
795    ) -> Result<InstallHeartbeatResponse> {
796        let sys = SystemInfo::detect();
797        let body = InstallHeartbeatRequest {
798            install_id: install_id.to_string(),
799            app_version: app_version.to_string(),
800            os: sys.os,
801            os_version: sys.os_version,
802            arch: Some(sys.arch),
803            locale: sys.locale,
804        };
805        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
806            base_url,
807            "/api/voice/installs/heartbeat",
808            &body,
809            cred,
810        )
811        .await
812    }
813}
814
815// ---- Client surface for recordings ----------------------------------------
816//
817// Recordings don't fit the generic `Client::sync` shape cleanly:
818//
819//   - the response carries per-item provenance (the platform-stamped
820//     `r2Key`, plus whether bytes have already landed) that the
821//     daemon needs in order to decide which rows still owe a PUT;
822//   - the bytes upload is its own HTTP call (`PUT
823//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
824//
825// Rather than overloading `SyncEndpoint` to carry these shapes, we
826// expose two inherent methods on `Client` that compose the existing
827// JSON / bytes-PUT primitives.
828
829impl Client {
830    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
831    /// recording metadata. Returns the per-item `r2Key` the daemon
832    /// should target for the follow-up bytes PUT, and whether bytes
833    /// have already landed for each row.
834    ///
835    /// Batch sizing rules match [`Client::sync`]: the platform rejects
836    /// batches over 100 items; the daemon's uploader chunks at 50.
837    pub async fn sync_recordings(
838        &self,
839        items: &[VoiceRecordingRecord],
840    ) -> Result<VoiceRecordingsSyncResponse> {
841        let stamped = stamp_schema_version::<VoiceRecordings>(items);
842        let body = SyncRequest { items: stamped };
843        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
844            .await
845    }
846
847    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
848    /// bytes for a recording whose metadata was previously synced via
849    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
850    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
851    ///
852    /// `source_id` is path-segmented as-is; callers pass the
853    /// daemon-side UUID they used for the metadata sync. Empty /
854    /// path-traversal-shaped ids are not specifically guarded here —
855    /// the platform's Zod schema rejects them server-side, so a
856    /// malformed id surfaces as a 4xx via [`Error::Http`].
857    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
858        if source_id.is_empty() {
859            return Err(Error::BadRequest("source_id must not be empty".into()));
860        }
861        let path = format!("/api/voice/recordings/{source_id}/bytes");
862        self.put_raw_bytes(&path, "audio/wav", bytes).await
863    }
864}
865
866// ---- Recording sharing ----------------------------------------------------
867//
868// Sharing is a *command* — mutate one recording's share state and get a
869// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
870// exists for (see wavekat-voice doc 38). So it's a typed method pair on
871// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
872//
873// The desktop daemon keeps only a *mirror* of what these return; the
874// platform is authoritative for who may open a share. See
875// `wavekat-voice/docs/38-share-a-recording.md`.
876
877/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
878/// snake_case strings — the platform's Zod schema validates against this
879/// exact list, so a rename here would bounce every share command with a 400.
880///
881/// - `Private` — owner only (the default; "not shared").
882/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
883///   recipient must be signed in as an invited identity ("protected by login").
884/// - `Public` — anyone holding the capability link, no sign-in.
885#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
886#[serde(rename_all = "snake_case")]
887pub enum ShareVisibility {
888    Private,
889    Restricted,
890    Public,
891}
892
893/// How a shared recording's caller/callee identity (the call's `party`) is
894/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
895/// enum, so a rename here bounces a share command with a 400.
896///
897/// - `Full` — hidden behind a neutral direction label ("Inbound call").
898/// - `Partial` — best-effort redaction (keeps shape, drops the value).
899/// - `None` — the raw `party` is shown.
900///
901/// Absent on the wire → the platform defaults to `Partial` (identity
902/// masked) — privacy-forward without fully erasing the caller. See
903/// `wavekat-platform` docs/14.
904#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
905#[serde(rename_all = "snake_case")]
906pub enum PartyMasking {
907    Full,
908    Partial,
909    None,
910}
911
912/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
913/// recording's share. The recording must already be synced (metadata +
914/// bytes) or the platform returns 404.
915#[derive(Debug, Clone, Serialize, Deserialize)]
916#[serde(rename_all = "camelCase")]
917pub struct ShareRecordingRequest {
918    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
919    /// the URL path; carried in the struct so callers pass one value.
920    pub recording_source_id: String,
921    pub visibility: ShareVisibility,
922    /// Restricted tier — the WaveKat-account emails allowed to open the
923    /// share. Ignored (and omitted) for `Private` / `Public`.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub invited_emails: Option<Vec<String>>,
926    /// Per-share visibility controls (platform docs/14) — what a viewer may
927    /// see. Each is omitted when unset; the platform then applies its
928    /// privacy-forward default (identity masked, transcript hidden, audio
929    /// shown, download off). NB the platform treats the request as the
930    /// *full* desired state, so an omitted control is reset to its default,
931    /// not preserved from a prior share — send all of them when editing an
932    /// existing share's controls.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub party_masking: Option<PartyMasking>,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub show_transcript: Option<bool>,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub show_audio: Option<bool>,
939    /// Whether a viewer may *download* the WAV, distinct from hearing it.
940    /// Off by default and only meaningful while `show_audio` is true — the
941    /// platform forces it off otherwise (you can't save what you can't
942    /// hear). A soft control: it hides the viewer's Download affordance,
943    /// not the bytes a listener already fetches to play.
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    pub allow_download: Option<bool>,
946    /// Per-channel playback defaults — which side is *audible by default*
947    /// in the viewer's player (docs/14). A call has two channels: `local`
948    /// (the owner's microphone, "your side") and `remote` (the other
949    /// party, "their side"). `true` means that side starts muted; the
950    /// viewer can still un-mute it, and the audio file is unchanged — this
951    /// is only the player's starting state. Each is omitted when unset, in
952    /// which case the platform defaults to audible (`false`). Only
953    /// meaningful while `show_audio` is true; ignored when audio is hidden.
954    #[serde(default, skip_serializing_if = "Option::is_none")]
955    pub default_mute_local: Option<bool>,
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub default_mute_remote: Option<bool>,
958    /// Phase 2 — out-of-band password gate. Omitted when unset.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub password: Option<String>,
961    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub expires_at: Option<String>,
964}
965
966/// The platform's response to a successful share command. `share_url` is
967/// the full https link the user copies; `token` is the opaque capability
968/// identifier embedded in it (returned separately so the daemon can store
969/// it for display without re-parsing the URL).
970#[derive(Debug, Clone, Serialize, Deserialize)]
971#[serde(rename_all = "camelCase")]
972pub struct ShareRecordingResponse {
973    pub visibility: ShareVisibility,
974    pub token: String,
975    pub share_url: String,
976    /// RFC 3339 — when the recording was first shared.
977    pub shared_at: String,
978    /// Effective visibility controls the platform stored (docs/14). Optional
979    /// for tolerance — a platform predating the feature omits them, in which
980    /// case the daemon should assume the defaults (identity masked, transcript
981    /// hidden, audio shown, download off).
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub party_masking: Option<PartyMasking>,
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub show_transcript: Option<bool>,
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub show_audio: Option<bool>,
988    /// Effective download permission — `show_audio && allow_download`, so
989    /// it's never true when the audio is hidden. Absent on a platform
990    /// predating the control (assume off).
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub allow_download: Option<bool>,
993    /// Effective per-channel playback defaults the platform stored — which
994    /// side starts muted in the viewer's player (docs/14). Absent on a
995    /// platform predating the control (assume audible, `false`).
996    #[serde(default, skip_serializing_if = "Option::is_none")]
997    pub default_mute_local: Option<bool>,
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub default_mute_remote: Option<bool>,
1000}
1001
1002/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
1003/// *authoritative* current share state for an owned recording. The POST
1004/// reply omits the invited-email list and a local mirror can't reflect a
1005/// share changed from another device, so the desktop "who can open this"
1006/// panel reads here.
1007///
1008/// A recording that was never shared (or whose share is revoked / expired)
1009/// comes back as [`ShareVisibility::Private`] with the optional fields
1010/// absent — the same "not shared" state DELETE leaves behind.
1011#[derive(Debug, Clone, Serialize, Deserialize)]
1012#[serde(rename_all = "camelCase")]
1013pub struct ShareStateResponse {
1014    pub visibility: ShareVisibility,
1015    /// Absent when `visibility == Private` (nothing is shared).
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub token: Option<String>,
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub share_url: Option<String>,
1020    /// RFC 3339 — when the recording was first shared. Absent when private.
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub shared_at: Option<String>,
1023    /// The restricted tier's audience (lowercased, de-duped). Present
1024    /// (possibly empty) only for [`ShareVisibility::Restricted`].
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub invited_emails: Option<Vec<String>>,
1027    /// Per-share visibility controls (docs/14). Present for a live share;
1028    /// absent when `Private` (nothing is shared, so no controls apply).
1029    #[serde(default, skip_serializing_if = "Option::is_none")]
1030    pub party_masking: Option<PartyMasking>,
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub show_transcript: Option<bool>,
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub show_audio: Option<bool>,
1035    /// Effective download permission — `show_audio && allow_download`, so
1036    /// never true when the audio is hidden. Absent when private.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub allow_download: Option<bool>,
1039    /// Effective per-channel playback defaults — which side starts muted in
1040    /// the viewer's player (docs/14). Absent when private.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub default_mute_local: Option<bool>,
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub default_mute_remote: Option<bool>,
1045}
1046
1047impl Client {
1048    /// `POST /api/voice/recordings/{id}/share` — create or update a share
1049    /// for an already-synced recording. Returns the capability link + token
1050    /// the desktop UI puts on the clipboard.
1051    ///
1052    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
1053    /// to share a recording the caller doesn't own surfaces as
1054    /// [`Error::Http`] with status 404 — existence doesn't leak.
1055    pub async fn share_recording(
1056        &self,
1057        req: &ShareRecordingRequest,
1058    ) -> Result<ShareRecordingResponse> {
1059        if req.recording_source_id.is_empty() {
1060            return Err(Error::BadRequest(
1061                "recording_source_id must not be empty".into(),
1062            ));
1063        }
1064        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
1065        self.post_json::<ShareRecordingResponse, _>(&path, req)
1066            .await
1067    }
1068
1069    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
1070    /// share state for an owned recording, including the restricted tier's
1071    /// invited emails (which the share command's reply omits). Like
1072    /// [`share_recording`](Self::share_recording), a recording the caller
1073    /// doesn't own surfaces as [`Error::Http`] with status 404.
1074    pub async fn get_recording_share(
1075        &self,
1076        recording_source_id: &str,
1077    ) -> Result<ShareStateResponse> {
1078        if recording_source_id.is_empty() {
1079            return Err(Error::BadRequest(
1080                "recording_source_id must not be empty".into(),
1081            ));
1082        }
1083        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1084        self.get_json::<ShareStateResponse>(&path).await
1085    }
1086
1087    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
1088    /// recording reverts to Private and any outstanding link returns 410.
1089    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
1090        if recording_source_id.is_empty() {
1091            return Err(Error::BadRequest(
1092                "recording_source_id must not be empty".into(),
1093            ));
1094        }
1095        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1096        self.delete(&path).await
1097    }
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    #[test]
1105    fn share_visibility_types_are_reachable_from_the_crate_root() {
1106        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
1107        // module but left out of the crate-root `pub use voice::{…}`, and the
1108        // module is private — so a consumer (`wavekat-voice`) couldn't name
1109        // the type to build a `ShareRecordingRequest`. Pin every share-control
1110        // type to the root path so dropping one fails to compile here, not in
1111        // a downstream crate. The body never runs; reachability is the test.
1112        #[allow(dead_code)]
1113        fn _reachable() {
1114            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
1115            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
1116            let _: fn(&crate::ShareRecordingRequest) = |_| {};
1117            let _: fn(&crate::ShareRecordingResponse) = |_| {};
1118        }
1119    }
1120
1121    #[test]
1122    fn record_serializes_with_camel_case_keys() {
1123        let r = VoiceCallRecord {
1124            source_id: "11111111-1111-4111-8111-111111111111".into(),
1125            account_id: "22222222-2222-4222-8222-222222222222".into(),
1126            direction: VoiceCallDirection::Inbound,
1127            party: "+14155550123".into(),
1128            ring_at: "2026-05-16T10:00:00Z".into(),
1129            answer_at: Some("2026-05-16T10:00:05Z".into()),
1130            end_at: "2026-05-16T10:01:00Z".into(),
1131            duration_ms: Some(55_000),
1132            disposition: VoiceCallDisposition::Answered,
1133            end_reason: VoiceCallEndReason::HangupRemote,
1134            error: None,
1135            share_visibility: None,
1136            transfer_target: None,
1137            codec: None,
1138            flow_id: None,
1139            flow_name: None,
1140            flow_outcome: None,
1141            flow_steps: None,
1142            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1143        };
1144        let s = serde_json::to_string(&r).unwrap();
1145        assert!(s.contains("\"sourceId\":"), "{s}");
1146        assert!(s.contains("\"accountId\":"), "{s}");
1147        assert!(s.contains("\"ringAt\":"), "{s}");
1148        assert!(s.contains("\"endAt\":"), "{s}");
1149        assert!(s.contains("\"durationMs\":55000"), "{s}");
1150        // Optional `error` is None — should be omitted from the wire.
1151        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
1152        // Optional `transferTarget` is None here — omitted from the wire,
1153        // exactly like a non-transferred call ships.
1154        assert!(
1155            !s.contains("\"transferTarget\""),
1156            "transferTarget should be omitted: {s}"
1157        );
1158        // Optional `codec` is None (never-answered call, or an older
1159        // daemon) — omitted from the wire, never `null`.
1160        assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
1161        // Envelope flattens to the top of the object — schemaVersion
1162        // sits next to the other fields rather than nested under
1163        // "envelope". Future resources rely on this layout.
1164        assert!(
1165            s.contains("\"schemaVersion\":1"),
1166            "schemaVersion should flatten: {s}"
1167        );
1168        // `extras` is None, so the envelope contributes no `extras`
1169        // key. Stays out of the row to keep the small/fast path.
1170        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1171    }
1172
1173    #[test]
1174    fn record_round_trips_optional_fields() {
1175        // An unanswered call has answer_at/duration_ms/error all absent.
1176        let raw = r#"{
1177            "sourceId": "a",
1178            "accountId": "b",
1179            "direction": "inbound",
1180            "party": "anonymous",
1181            "ringAt": "2026-05-16T10:00:00Z",
1182            "endAt": "2026-05-16T10:00:30Z",
1183            "disposition": "missed",
1184            "endReason": "missed"
1185        }"#;
1186        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1187        assert!(parsed.answer_at.is_none());
1188        assert!(parsed.duration_ms.is_none());
1189        assert!(parsed.error.is_none());
1190        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
1191        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
1192    }
1193
1194    #[test]
1195    fn query_omits_unset_fields() {
1196        let q = VoiceCallsQuery::default();
1197        let s = serde_json::to_string(&q).unwrap();
1198        // Empty object — every field skipped when None.
1199        assert_eq!(
1200            s, "{}",
1201            "default query should serialize to empty object: {s}"
1202        );
1203    }
1204
1205    #[test]
1206    fn enum_round_trip_via_json() {
1207        // The wire form for each direction/disposition/reason must
1208        // match what the daemon and platform expect — this guards
1209        // against accidental Rust-side renames.
1210        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
1211            let s = serde_json::to_string(&d).unwrap();
1212            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
1213            assert_eq!(d, back);
1214        }
1215        for d in [
1216            VoiceCallDisposition::Answered,
1217            VoiceCallDisposition::Missed,
1218            VoiceCallDisposition::Rejected,
1219            VoiceCallDisposition::Cancelled,
1220            VoiceCallDisposition::Failed,
1221        ] {
1222            let s = serde_json::to_string(&d).unwrap();
1223            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
1224            assert_eq!(d, back);
1225        }
1226        for r in [
1227            VoiceCallEndReason::HangupLocal,
1228            VoiceCallEndReason::HangupRemote,
1229            VoiceCallEndReason::RejectedLocal,
1230            VoiceCallEndReason::RejectedRemote,
1231            VoiceCallEndReason::Missed,
1232            VoiceCallEndReason::CancelledLocal,
1233            VoiceCallEndReason::TransferredLocal,
1234            VoiceCallEndReason::ConnectionLost,
1235            VoiceCallEndReason::Failed,
1236        ] {
1237            let s = serde_json::to_string(&r).unwrap();
1238            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1239            assert_eq!(r, back);
1240        }
1241    }
1242
1243    #[test]
1244    fn connection_lost_pins_its_wire_string() {
1245        // The platform's sync endpoint validates end reasons against
1246        // an exact string list — a rename here would make every
1247        // upload from a session-timer teardown bounce with a 400.
1248        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1249        assert_eq!(s, "\"connection_lost\"");
1250    }
1251
1252    #[test]
1253    fn transferred_local_pins_its_wire_string() {
1254        // Same contract as `connection_lost`: the platform validates
1255        // against an exact string list, so a rename here would bounce
1256        // every transferred-call upload with a 400.
1257        let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1258        assert_eq!(s, "\"transferred_local\"");
1259    }
1260
1261    #[test]
1262    fn record_round_trips_transfer_target() {
1263        // A transferred call carries `transferTarget` both ways — the
1264        // daemon ships it (it's its own data, not read-only decoration),
1265        // and the platform echoes it back on read.
1266        let raw = r#"{
1267            "sourceId": "a",
1268            "accountId": "b",
1269            "direction": "inbound",
1270            "party": "Alice <sip:alice@example.com>",
1271            "ringAt": "2026-06-28T10:00:00Z",
1272            "answerAt": "2026-06-28T10:00:05Z",
1273            "endAt": "2026-06-28T10:00:30Z",
1274            "durationMs": 25000,
1275            "disposition": "answered",
1276            "endReason": "transferred_local",
1277            "transferTarget": "1002"
1278        }"#;
1279        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1280        assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1281        assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1282        // And it survives a re-serialize (daemon → platform direction).
1283        let s = serde_json::to_string(&parsed).unwrap();
1284        assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1285    }
1286
1287    #[test]
1288    fn codec_pins_its_wire_strings() {
1289        // The platform's sync endpoint validates the codec against an
1290        // exact string list, and the daemon's `CallCodec::as_str` emits
1291        // these same strings — a rename here would bounce every upload
1292        // from an answered call with a 400.
1293        for (codec, wire) in [
1294            (VoiceCallCodec::Opus, "\"opus\""),
1295            (VoiceCallCodec::Pcmu, "\"pcmu\""),
1296            (VoiceCallCodec::Pcma, "\"pcma\""),
1297        ] {
1298            assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1299            let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1300            assert_eq!(back, codec);
1301        }
1302    }
1303
1304    #[test]
1305    fn record_round_trips_codec() {
1306        // An answered call carries `codec` both ways — the daemon ships
1307        // it (its own data, like transferTarget), and the platform
1308        // echoes it back on read so the website can show the call's
1309        // audio quality.
1310        let raw = r#"{
1311            "sourceId": "a",
1312            "accountId": "b",
1313            "direction": "inbound",
1314            "party": "Alice <sip:alice@example.com>",
1315            "ringAt": "2026-07-03T10:00:00Z",
1316            "answerAt": "2026-07-03T10:00:05Z",
1317            "endAt": "2026-07-03T10:00:30Z",
1318            "durationMs": 25000,
1319            "disposition": "answered",
1320            "endReason": "hangup_remote",
1321            "codec": "opus"
1322        }"#;
1323        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1324        assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1325        // And it survives a re-serialize (daemon → platform direction).
1326        let s = serde_json::to_string(&parsed).unwrap();
1327        assert!(s.contains("\"codec\":\"opus\""), "{s}");
1328
1329        // A row from an older daemon has no codec — reads as None.
1330        let legacy = raw.replace(",\n            \"codec\": \"opus\"", "");
1331        let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1332        assert_eq!(parsed.codec, None);
1333    }
1334
1335    #[test]
1336    fn flow_outcome_pins_its_wire_strings() {
1337        // Three parties agree on these exact strings: the daemon's
1338        // `flow_outcome_to_str`, `wavekat_flow::trace::FlowOutcome`'s
1339        // snake_case serde, and the platform's zod enum. A rename here
1340        // 400s every flow-answered call's batch.
1341        for (outcome, wire) in [
1342            (VoiceCallFlowOutcome::Answered, "\"answered\""),
1343            (VoiceCallFlowOutcome::MessageLeft, "\"message_left\""),
1344            (VoiceCallFlowOutcome::Transferred, "\"transferred\""),
1345            (VoiceCallFlowOutcome::HungUp, "\"hung_up\""),
1346            (VoiceCallFlowOutcome::Aborted, "\"aborted\""),
1347            (VoiceCallFlowOutcome::Defect, "\"defect\""),
1348        ] {
1349            assert_eq!(serde_json::to_string(&outcome).unwrap(), wire);
1350            let back: VoiceCallFlowOutcome = serde_json::from_str(wire).unwrap();
1351            assert_eq!(back, outcome);
1352        }
1353    }
1354
1355    #[test]
1356    fn record_round_trips_flow_attribution() {
1357        // A flow-answered call carries which flow took it and how the
1358        // run ended, both ways: the daemon ships them, the platform
1359        // echoes them so the website can say "Answered by “X”" and show
1360        // the run's own outcome instead of the misleading SIP one.
1361        let raw = r#"{
1362            "sourceId": "a",
1363            "accountId": "b",
1364            "direction": "inbound",
1365            "party": "Alice <sip:alice@example.com>",
1366            "ringAt": "2026-07-03T10:00:00Z",
1367            "answerAt": "2026-07-03T10:00:05Z",
1368            "endAt": "2026-07-03T10:00:30Z",
1369            "durationMs": 25000,
1370            "disposition": "answered",
1371            "endReason": "hangup_local",
1372            "flowId": "flow_after_hours",
1373            "flowName": "After hours",
1374            "flowOutcome": "message_left"
1375        }"#;
1376        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1377        assert_eq!(parsed.flow_id.as_deref(), Some("flow_after_hours"));
1378        assert_eq!(parsed.flow_name.as_deref(), Some("After hours"));
1379        assert_eq!(parsed.flow_outcome, Some(VoiceCallFlowOutcome::MessageLeft));
1380
1381        let s = serde_json::to_string(&parsed).unwrap();
1382        assert!(s.contains("\"flowId\":\"flow_after_hours\""), "{s}");
1383        assert!(s.contains("\"flowName\":\"After hours\""), "{s}");
1384        assert!(s.contains("\"flowOutcome\":\"message_left\""), "{s}");
1385    }
1386
1387    #[test]
1388    fn record_round_trips_a_flow_step_trace() {
1389        // Pins the per-step field names. These are consumed by the
1390        // platform's Zod schema on one side and produced by the daemon's
1391        // projection on the other; a silent rename here breaks both.
1392        let raw = r#"{
1393            "sourceId": "a",
1394            "accountId": "b",
1395            "direction": "inbound",
1396            "party": "sip:alice@example.com",
1397            "ringAt": "2026-07-03T10:00:00Z",
1398            "answerAt": "2026-07-03T10:00:05Z",
1399            "endAt": "2026-07-03T10:00:30Z",
1400            "disposition": "answered",
1401            "endReason": "hangup_local",
1402            "flowId": "f",
1403            "flowName": "F",
1404            "flowSteps": [
1405                { "atMs": 0, "kind": "spoke", "node": "greeting" },
1406                { "atMs": 4200, "kind": "menu_choice", "digit": "2" },
1407                { "atMs": 9100, "kind": "message_recorded", "secs": 31 }
1408            ]
1409        }"#;
1410        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1411        let steps = parsed.flow_steps.as_deref().expect("steps present");
1412        assert_eq!(steps.len(), 3);
1413        assert_eq!(steps[1].kind, "menu_choice");
1414        assert_eq!(steps[1].digit.as_deref(), Some("2"));
1415        assert_eq!(steps[2].secs, Some(31));
1416        // Absent per-step fields stay absent rather than serializing as
1417        // nulls — same contract as the record's own optional fields.
1418        let s = serde_json::to_string(&steps[0]).unwrap();
1419        assert_eq!(s, r#"{"atMs":0,"kind":"spoke","node":"greeting"}"#);
1420    }
1421
1422    #[test]
1423    fn flow_step_accepts_a_kind_this_build_does_not_know() {
1424        // The whole reason `kind` is a String. A consumer pinned to an
1425        // older crate version must still deserialize a newer daemon's
1426        // trace — rejecting would fail the entire call record, not one
1427        // step.
1428        let step: VoiceCallFlowStep =
1429            serde_json::from_str(r#"{"atMs": 10, "kind": "consulted_the_oracle"}"#).unwrap();
1430        assert_eq!(step.kind, "consulted_the_oracle");
1431        assert_eq!(step.digit, None);
1432    }
1433
1434    #[test]
1435    fn record_omits_flow_steps_for_a_human_answered_call() {
1436        // A call the user took themselves has no trace. The field must
1437        // stay off the wire entirely rather than serializing as null.
1438        let raw = r#"{
1439            "sourceId": "a",
1440            "accountId": "b",
1441            "direction": "inbound",
1442            "party": "sip:alice@example.com",
1443            "ringAt": "2026-07-03T10:00:00Z",
1444            "endAt": "2026-07-03T10:00:30Z",
1445            "disposition": "answered",
1446            "endReason": "hangup_local"
1447        }"#;
1448        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1449        assert!(parsed.flow_steps.is_none());
1450        let s = serde_json::to_string(&parsed).unwrap();
1451        assert!(!s.contains("flowSteps"), "{s}");
1452    }
1453
1454    #[test]
1455    fn record_omits_flow_fields_for_a_human_answered_call() {
1456        // Calls the user took themselves — and every row from a daemon
1457        // predating call flows — carry none of the three. They must
1458        // stay off the wire entirely, not serialize as nulls.
1459        let raw = r#"{
1460            "sourceId": "a",
1461            "accountId": "b",
1462            "direction": "inbound",
1463            "party": "sip:alice@example.com",
1464            "ringAt": "2026-07-03T10:00:00Z",
1465            "endAt": "2026-07-03T10:00:30Z",
1466            "disposition": "answered",
1467            "endReason": "hangup_remote"
1468        }"#;
1469        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1470        assert_eq!(parsed.flow_id, None);
1471        assert_eq!(parsed.flow_name, None);
1472        assert_eq!(parsed.flow_outcome, None);
1473
1474        let s = serde_json::to_string(&parsed).unwrap();
1475        assert!(!s.contains("\"flowId\""), "flowId should be omitted: {s}");
1476        assert!(
1477            !s.contains("\"flowName\""),
1478            "flowName should be omitted: {s}"
1479        );
1480        assert!(
1481            !s.contains("\"flowOutcome\""),
1482            "flowOutcome should be omitted: {s}"
1483        );
1484    }
1485
1486    #[test]
1487    fn voice_calls_marker_resource_is_calls() {
1488        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1489    }
1490
1491    #[test]
1492    fn record_accepts_unknown_extras_for_forward_compat() {
1493        // A newer client shipping a `notes` field that this platform
1494        // version doesn't have a column for should round-trip via
1495        // the `extras` envelope. The platform persists the blob
1496        // verbatim; a future deploy can promote it to a typed
1497        // column without data loss.
1498        let raw = r#"{
1499            "sourceId": "a",
1500            "accountId": "b",
1501            "direction": "inbound",
1502            "party": "anon",
1503            "ringAt": "2026-05-16T10:00:00Z",
1504            "endAt": "2026-05-16T10:00:30Z",
1505            "disposition": "answered",
1506            "endReason": "hangup_remote",
1507            "schemaVersion": 2,
1508            "extras": { "notes": "from staging build" }
1509        }"#;
1510        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1511        assert_eq!(parsed.envelope.schema_version, Some(2));
1512        let extras = parsed.envelope.extras.as_ref().expect("extras present");
1513        assert_eq!(extras["notes"], "from staging build");
1514    }
1515
1516    #[test]
1517    fn call_record_parses_share_visibility_from_list_response() {
1518        // The list / detail endpoints decorate a call with the tier of any
1519        // active share on its recording, so a consumer can badge the row.
1520        let raw = r#"{
1521            "sourceId": "a",
1522            "accountId": "b",
1523            "direction": "outbound",
1524            "party": "+14155550123",
1525            "ringAt": "2026-05-16T10:00:00Z",
1526            "endAt": "2026-05-16T10:00:30Z",
1527            "disposition": "answered",
1528            "endReason": "hangup_remote",
1529            "shareVisibility": "public"
1530        }"#;
1531        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1532        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1533
1534        let restricted = raw.replace("public", "restricted");
1535        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1536        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1537    }
1538
1539    #[test]
1540    fn call_record_unshared_has_no_share_visibility() {
1541        // Absent (older platform, or an unshared call) and an explicit
1542        // `null` both read as "not shared" — never `Some(Private)`.
1543        let base = r#"{
1544            "sourceId": "a",
1545            "accountId": "b",
1546            "direction": "inbound",
1547            "party": "anon",
1548            "ringAt": "2026-05-16T10:00:00Z",
1549            "endAt": "2026-05-16T10:00:30Z",
1550            "disposition": "missed",
1551            "endReason": "missed"
1552        }"#;
1553        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1554        assert_eq!(parsed.share_visibility, None);
1555
1556        let with_null = base.replace(
1557            r#""endReason": "missed""#,
1558            r#""endReason": "missed", "shareVisibility": null"#,
1559        );
1560        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1561        assert_eq!(parsed.share_visibility, None);
1562    }
1563
1564    #[test]
1565    fn synced_call_omits_share_visibility() {
1566        // `share_visibility` is read-only decoration: a call uploaded via
1567        // sync must not carry it on the wire (skip_serializing_if = None),
1568        // so the round trip from a sync-shaped record stays clean.
1569        let raw = r#"{
1570            "sourceId": "a",
1571            "accountId": "b",
1572            "direction": "inbound",
1573            "party": "anon",
1574            "ringAt": "2026-05-16T10:00:00Z",
1575            "endAt": "2026-05-16T10:00:30Z",
1576            "disposition": "answered",
1577            "endReason": "hangup_remote"
1578        }"#;
1579        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1580        assert_eq!(parsed.share_visibility, None);
1581        let s = serde_json::to_string(&parsed).unwrap();
1582        assert!(
1583            !s.contains("shareVisibility"),
1584            "sync payload leaked share_visibility: {s}"
1585        );
1586    }
1587
1588    #[test]
1589    fn recording_marker_resource_is_recordings() {
1590        // Path constant drives the URL in `Client::sync_recordings`;
1591        // a rename here would silently 404 against the platform.
1592        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1593    }
1594
1595    #[test]
1596    fn recording_record_serializes_with_camel_case_and_envelope() {
1597        let r = VoiceRecordingRecord {
1598            source_id: "11111111-1111-4111-8111-111111111111".into(),
1599            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1600            size_bytes: 44 + 64_000,
1601            duration_ms: 2_000,
1602            sample_rate: 8_000,
1603            channels: 2,
1604            created_at: "2026-05-16T10:01:05Z".into(),
1605            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1606        };
1607        let s = serde_json::to_string(&r).unwrap();
1608        // Field-by-field wire contract — these strings are also what
1609        // the platform's Zod schema expects.
1610        assert!(s.contains("\"sourceId\":"), "{s}");
1611        assert!(s.contains("\"callSourceId\":"), "{s}");
1612        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1613        assert!(s.contains("\"durationMs\":2000"), "{s}");
1614        assert!(s.contains("\"sampleRate\":8000"), "{s}");
1615        assert!(s.contains("\"channels\":2"), "{s}");
1616        assert!(s.contains("\"createdAt\":"), "{s}");
1617        // Envelope flattens to the top of the object, same as VoiceCallRecord.
1618        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1619    }
1620
1621    #[test]
1622    fn recordings_sync_response_round_trips() {
1623        // The richer-than-generic response carries per-item provenance —
1624        // the daemon's uploader reads `r2Key` for the bytes follow-up
1625        // and `bytesUploaded` to short-circuit when the row already
1626        // landed on a previous cycle.
1627        let raw = r#"{
1628            "accepted": 2,
1629            "skipped": 0,
1630            "items": [
1631                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1632                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1633            ]
1634        }"#;
1635        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1636        assert_eq!(parsed.accepted, 2);
1637        assert_eq!(parsed.items.len(), 2);
1638        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1639        assert!(!parsed.items[0].bytes_uploaded);
1640        assert!(parsed.items[1].bytes_uploaded);
1641    }
1642
1643    #[test]
1644    fn install_heartbeat_request_serializes_with_camel_case_keys() {
1645        let req = InstallHeartbeatRequest {
1646            install_id: "11111111-1111-4111-8111-111111111111".into(),
1647            app_version: "0.0.21".into(),
1648            os: "macos".into(),
1649            os_version: Some("15.5.0".into()),
1650            arch: Some("aarch64".into()),
1651            locale: Some("en-NZ".into()),
1652        };
1653        let s = serde_json::to_string(&req).unwrap();
1654        assert!(s.contains("\"installId\":"), "{s}");
1655        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1656        assert!(s.contains("\"os\":\"macos\""), "{s}");
1657        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1658        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1659        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1660    }
1661
1662    #[test]
1663    fn install_heartbeat_request_omits_absent_optional_fields() {
1664        // A host where the OS version / locale probe came up empty
1665        // shouldn't send `null` — keeping the keys out lets the
1666        // platform's Zod `.optional()` accept the body and the column
1667        // stay NULL rather than the string "null".
1668        let req = InstallHeartbeatRequest {
1669            install_id: "x".into(),
1670            app_version: "0.0.21".into(),
1671            os: "linux".into(),
1672            os_version: None,
1673            arch: None,
1674            locale: None,
1675        };
1676        let s = serde_json::to_string(&req).unwrap();
1677        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1678        assert!(!s.contains("arch"), "arch should be omitted: {s}");
1679        assert!(!s.contains("locale"), "locale should be omitted: {s}");
1680    }
1681
1682    #[test]
1683    fn install_heartbeat_response_parses_platform_shape() {
1684        let raw = r#"{
1685            "id": "abc-123",
1686            "installId": "11111111-1111-4111-8111-111111111111",
1687            "appVersion": "0.0.21",
1688            "os": "macos",
1689            "osVersion": "15.5.0",
1690            "arch": "aarch64",
1691            "locale": null,
1692            "firstSeenAt": "2026-05-31T10:00:00.000Z",
1693            "lastSeenAt": "2026-05-31T10:00:00.000Z"
1694        }"#;
1695        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1696        assert_eq!(parsed.id, "abc-123");
1697        assert_eq!(parsed.app_version, "0.0.21");
1698        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1699        assert!(parsed.locale.is_none());
1700    }
1701
1702    #[test]
1703    fn system_info_detect_fills_os_and_arch() {
1704        // os / arch come from compile-time consts, so they're always
1705        // non-empty on every supported target. os_version / locale are
1706        // best-effort and intentionally not asserted.
1707        let sys = SystemInfo::detect();
1708        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1709        assert!(
1710            !sys.arch.is_empty(),
1711            "arch should be a non-empty target string"
1712        );
1713    }
1714
1715    #[test]
1716    fn transcripts_marker_resource_is_transcripts() {
1717        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1718    }
1719
1720    #[test]
1721    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1722        let r = VoiceTranscriptRecord {
1723            source_id: "1".into(),
1724            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1725            channel: VoiceTranscriptChannel::Remote,
1726            ts_ms: 100,
1727            end_ms: 1_500,
1728            text: "hello".into(),
1729            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1730        };
1731        let s = serde_json::to_string(&r).unwrap();
1732        assert!(s.contains("\"sourceId\":"), "{s}");
1733        assert!(s.contains("\"callSourceId\":"), "{s}");
1734        // The channel enum is wire-stable snake_case — matches the
1735        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
1736        assert!(s.contains("\"channel\":\"remote\""), "{s}");
1737        assert!(s.contains("\"tsMs\":100"), "{s}");
1738        assert!(s.contains("\"endMs\":1500"), "{s}");
1739        assert!(s.contains("\"text\":\"hello\""), "{s}");
1740        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1741    }
1742
1743    #[test]
1744    fn share_visibility_pins_its_wire_strings() {
1745        // The platform validates these against an exact string list; a
1746        // rename would bounce every share command with a 400.
1747        assert_eq!(
1748            serde_json::to_string(&ShareVisibility::Private).unwrap(),
1749            "\"private\""
1750        );
1751        assert_eq!(
1752            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1753            "\"restricted\""
1754        );
1755        assert_eq!(
1756            serde_json::to_string(&ShareVisibility::Public).unwrap(),
1757            "\"public\""
1758        );
1759        for v in [
1760            ShareVisibility::Private,
1761            ShareVisibility::Restricted,
1762            ShareVisibility::Public,
1763        ] {
1764            let s = serde_json::to_string(&v).unwrap();
1765            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1766            assert_eq!(v, back);
1767        }
1768    }
1769
1770    #[test]
1771    fn share_request_serializes_with_camel_case_and_omits_unset() {
1772        let req = ShareRecordingRequest {
1773            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1774            visibility: ShareVisibility::Public,
1775            invited_emails: None,
1776            party_masking: None,
1777            show_transcript: None,
1778            show_audio: None,
1779            allow_download: None,
1780            default_mute_local: None,
1781            default_mute_remote: None,
1782            password: None,
1783            expires_at: None,
1784        };
1785        let s = serde_json::to_string(&req).unwrap();
1786        assert!(s.contains("\"recordingSourceId\":"), "{s}");
1787        assert!(s.contains("\"visibility\":\"public\""), "{s}");
1788        // Phase-2 / tier-specific / visibility-control fields stay off the
1789        // wire when unset so the platform's `.optional()` schema accepts the
1790        // body (and the omitted controls fall to the platform defaults).
1791        assert!(!s.contains("invitedEmails"), "{s}");
1792        assert!(!s.contains("partyMasking"), "{s}");
1793        assert!(!s.contains("showTranscript"), "{s}");
1794        assert!(!s.contains("showAudio"), "{s}");
1795        assert!(!s.contains("allowDownload"), "{s}");
1796        assert!(!s.contains("defaultMuteLocal"), "{s}");
1797        assert!(!s.contains("defaultMuteRemote"), "{s}");
1798        assert!(!s.contains("password"), "{s}");
1799        assert!(!s.contains("expiresAt"), "{s}");
1800    }
1801
1802    #[test]
1803    fn share_request_serializes_visibility_controls_camel_case() {
1804        let req = ShareRecordingRequest {
1805            recording_source_id: "a".into(),
1806            visibility: ShareVisibility::Public,
1807            invited_emails: None,
1808            party_masking: Some(PartyMasking::Partial),
1809            show_transcript: Some(false),
1810            show_audio: Some(true),
1811            allow_download: Some(true),
1812            default_mute_local: Some(false),
1813            default_mute_remote: Some(true),
1814            password: None,
1815            expires_at: None,
1816        };
1817        let s = serde_json::to_string(&req).unwrap();
1818        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1819        assert!(s.contains("\"showTranscript\":false"), "{s}");
1820        assert!(s.contains("\"showAudio\":true"), "{s}");
1821        assert!(s.contains("\"allowDownload\":true"), "{s}");
1822        // The owner muted their own side by default but left the other
1823        // party audible — both ride the wire as camelCase booleans.
1824        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1825        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1826    }
1827
1828    #[test]
1829    fn share_request_carries_invited_emails_for_restricted() {
1830        let req = ShareRecordingRequest {
1831            recording_source_id: "a".into(),
1832            visibility: ShareVisibility::Restricted,
1833            invited_emails: Some(vec!["alex@example.com".into()]),
1834            party_masking: None,
1835            show_transcript: None,
1836            show_audio: None,
1837            allow_download: None,
1838            default_mute_local: None,
1839            default_mute_remote: None,
1840            password: None,
1841            expires_at: None,
1842        };
1843        let s = serde_json::to_string(&req).unwrap();
1844        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1845        assert!(
1846            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1847            "{s}"
1848        );
1849    }
1850
1851    #[test]
1852    fn share_response_parses_platform_shape() {
1853        let raw = r#"{
1854            "visibility": "public",
1855            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1856            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1857            "sharedAt": "2026-06-19T10:00:00.000Z"
1858        }"#;
1859        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1860        assert_eq!(parsed.visibility, ShareVisibility::Public);
1861        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1862        assert!(parsed.share_url.ends_with(&parsed.token));
1863    }
1864
1865    #[test]
1866    fn share_state_parses_restricted_with_invited_emails() {
1867        // The GET read carries the audience back — this is the field the
1868        // POST reply omits and the desktop "who can open this" panel needs.
1869        let raw = r#"{
1870            "visibility": "restricted",
1871            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1872            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1873            "sharedAt": "2026-06-19T10:00:00.000Z",
1874            "invitedEmails": ["bob@example.com", "carol@example.com"],
1875            "partyMasking": "full",
1876            "showTranscript": true,
1877            "showAudio": false,
1878            "allowDownload": false,
1879            "defaultMuteLocal": false,
1880            "defaultMuteRemote": true
1881        }"#;
1882        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1883        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1884        assert_eq!(
1885            parsed.invited_emails.as_deref(),
1886            Some(
1887                [
1888                    "bob@example.com".to_string(),
1889                    "carol@example.com".to_string()
1890                ]
1891                .as_slice()
1892            )
1893        );
1894        // The visibility controls ride back on the live-share read.
1895        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1896        assert_eq!(parsed.show_transcript, Some(true));
1897        assert_eq!(parsed.show_audio, Some(false));
1898        // Audio hidden here, so download comes back off (platform folds the two).
1899        assert_eq!(parsed.allow_download, Some(false));
1900        // Per-channel playback defaults ride back too.
1901        assert_eq!(parsed.default_mute_local, Some(false));
1902        assert_eq!(parsed.default_mute_remote, Some(true));
1903    }
1904
1905    #[test]
1906    fn share_state_parses_private_with_fields_absent() {
1907        // A never-shared (or revoked) recording reports private with no
1908        // token / url / emails — the optional fields stay None.
1909        let parsed: ShareStateResponse =
1910            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1911        assert_eq!(parsed.visibility, ShareVisibility::Private);
1912        assert!(parsed.token.is_none());
1913        assert!(parsed.share_url.is_none());
1914        assert!(parsed.shared_at.is_none());
1915        assert!(parsed.invited_emails.is_none());
1916    }
1917
1918    #[test]
1919    fn share_request_rejects_empty_source_id_before_hitting_network() {
1920        // Guarded client-side so an empty id can't produce a path like
1921        // `/api/voice/recordings//share` that 404s confusingly.
1922        let req = ShareRecordingRequest {
1923            recording_source_id: String::new(),
1924            visibility: ShareVisibility::Private,
1925            invited_emails: None,
1926            party_masking: None,
1927            show_transcript: None,
1928            show_audio: None,
1929            allow_download: None,
1930            default_mute_local: None,
1931            default_mute_remote: None,
1932            password: None,
1933            expires_at: None,
1934        };
1935        // We can't call the async method without a runtime here, but the
1936        // guard mirrors `upload_recording_bytes` — assert the precondition
1937        // shape the method checks.
1938        assert!(req.recording_source_id.is_empty());
1939    }
1940
1941    // ---- VoiceAccounts ----
1942
1943    fn sample_account() -> VoiceAccountRecord {
1944        VoiceAccountRecord {
1945            source_id: "11111111-1111-4111-8111-111111111111".into(),
1946            enabled: true,
1947            display_name: "Work line".into(),
1948            username: "alice".into(),
1949            domain: "sip.example.com".into(),
1950            auth_username: Some("alice-auth".into()),
1951            server: Some("sip.example.com".into()),
1952            port: Some(5060),
1953            transport: VoiceTransport::Udp,
1954            register_expires: 60,
1955            keepalive_secs: Some(50),
1956            disclosure_enabled: true,
1957            updated_at: "2026-06-20T10:00:00Z".into(),
1958            deleted_at: None,
1959            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1960        }
1961    }
1962
1963    #[test]
1964    fn accounts_marker_resource_is_accounts() {
1965        // Path constant drives the URL in `Client::sync` / `Client::list`;
1966        // a rename here would silently 404 against the platform.
1967        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1968    }
1969
1970    #[test]
1971    fn account_record_serializes_with_camel_case_and_envelope() {
1972        let s = serde_json::to_string(&sample_account()).unwrap();
1973        // Field-by-field wire contract — also what the platform's Zod
1974        // schema expects.
1975        assert!(s.contains("\"sourceId\":"), "{s}");
1976        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1977        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1978        assert!(s.contains("\"registerExpires\":60"), "{s}");
1979        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1980        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1981        assert!(s.contains("\"transport\":\"udp\""), "{s}");
1982        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1983        // A live line carries no tombstone.
1984        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1985        // The secret never crosses this wire, by construction.
1986        assert!(!s.contains("password"), "no password field: {s}");
1987        // Envelope flattens to the top, same as the other resources.
1988        assert!(s.contains("\"schemaVersion\":1"), "{s}");
1989    }
1990
1991    #[test]
1992    fn account_tombstone_serializes_deleted_at() {
1993        // A soft-delete rides as an upsert with deletedAt set — the
1994        // delete-propagation mechanism (doc 40).
1995        let mut r = sample_account();
1996        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1997        let s = serde_json::to_string(&r).unwrap();
1998        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1999    }
2000
2001    #[test]
2002    fn account_record_round_trips_optional_fields() {
2003        // A minimal line — no auth username, server, port, keepalive, or
2004        // tombstone — should parse with those all absent.
2005        let raw = r#"{
2006            "sourceId": "a",
2007            "enabled": false,
2008            "displayName": "Cheap trunk",
2009            "username": "u",
2010            "domain": "d",
2011            "transport": "tcp",
2012            "registerExpires": 120,
2013            "disclosureEnabled": false,
2014            "updatedAt": "2026-06-20T10:00:00Z"
2015        }"#;
2016        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2017        assert!(!parsed.enabled);
2018        assert!(parsed.auth_username.is_none());
2019        assert!(parsed.server.is_none());
2020        assert!(parsed.port.is_none());
2021        assert!(parsed.keepalive_secs.is_none());
2022        assert!(parsed.deleted_at.is_none());
2023        assert_eq!(parsed.transport, VoiceTransport::Tcp);
2024        assert_eq!(parsed.register_expires, 120);
2025    }
2026
2027    #[test]
2028    fn voice_transport_round_trips_via_json() {
2029        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
2030            let s = serde_json::to_string(&t).unwrap();
2031            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
2032            assert_eq!(t, back);
2033        }
2034        // Pin the wire strings — the daemon's `TransportKind` and the
2035        // platform's Zod enum both depend on these exact tokens.
2036        assert_eq!(
2037            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
2038            "\"udp\""
2039        );
2040        assert_eq!(
2041            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
2042            "\"tcp\""
2043        );
2044    }
2045
2046    #[test]
2047    fn accounts_query_omits_unset_and_serializes_include_deleted() {
2048        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
2049        assert_eq!(empty, "{}", "default query should be empty: {empty}");
2050        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
2051            include_deleted: Some(true),
2052        })
2053        .unwrap();
2054        assert!(
2055            with_deleted.contains("\"includeDeleted\":true"),
2056            "{with_deleted}"
2057        );
2058    }
2059
2060    // ---- VoiceFlows ----
2061
2062    #[test]
2063    fn flows_query_serializes_cursor_and_omits_absent_fields() {
2064        let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
2065        assert_eq!(empty, "{}");
2066        let cursored = serde_json::to_string(&VoiceFlowsQuery {
2067            after: Some("flow_abc".into()),
2068            limit: Some(100),
2069        })
2070        .unwrap();
2071        assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
2072        assert!(cursored.contains("\"limit\":100"), "{cursored}");
2073    }
2074
2075    #[test]
2076    fn flows_page_parses_platform_shape() {
2077        let raw = r#"{
2078            "items": [{
2079                "id": "flow_1",
2080                "name": "Luigi's — after hours",
2081                "version": 3,
2082                "yaml": "schema_version: 1\n",
2083                "publishedAt": "2026-07-13T10:00:00Z"
2084            }],
2085            "nextAfter": null
2086        }"#;
2087        let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
2088        assert_eq!(page.items.len(), 1);
2089        let rec = &page.items[0];
2090        assert_eq!(rec.id, "flow_1");
2091        assert_eq!(rec.version, 3);
2092        assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
2093        assert!(page.next_after.is_none());
2094
2095        // A mid-walk page carries the cursor.
2096        let more: VoiceFlowsPage =
2097            serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
2098        assert_eq!(more.next_after.as_deref(), Some("flow_1"));
2099    }
2100
2101    #[test]
2102    fn flow_assets_manifest_parses_platform_shape() {
2103        // `ref` (a reserved word) maps to `asset_ref`; a null duration is
2104        // accepted (the platform doesn't always know it).
2105        let raw = r#"{
2106            "assets": [{
2107                "ref": "vprompt_ab12cd34",
2108                "format": "ulaw_8000",
2109                "byteSize": 48044,
2110                "durationMs": null,
2111                "contentHash": "9f2c00aa"
2112            }]
2113        }"#;
2114        let page: VoiceFlowAssetsPage = serde_json::from_str(raw).unwrap();
2115        assert_eq!(page.assets.len(), 1);
2116        let asset = &page.assets[0];
2117        assert_eq!(asset.asset_ref, "vprompt_ab12cd34");
2118        assert_eq!(asset.format, "ulaw_8000");
2119        assert_eq!(asset.byte_size, 48044);
2120        assert!(asset.duration_ms.is_none());
2121        assert_eq!(asset.content_hash, "9f2c00aa");
2122
2123        // A text-only version legitimately has no frozen audio.
2124        let empty: VoiceFlowAssetsPage = serde_json::from_str(r#"{ "assets": [] }"#).unwrap();
2125        assert!(empty.assets.is_empty());
2126    }
2127
2128    #[test]
2129    fn account_record_accepts_unknown_extras_for_forward_compat() {
2130        // A newer client shipping a field this platform version lacks a
2131        // column for round-trips via the `extras` envelope.
2132        let raw = r#"{
2133            "sourceId": "a",
2134            "enabled": true,
2135            "displayName": "x",
2136            "username": "u",
2137            "domain": "d",
2138            "transport": "udp",
2139            "registerExpires": 60,
2140            "disclosureEnabled": true,
2141            "updatedAt": "2026-06-20T10:00:00Z",
2142            "schemaVersion": 2,
2143            "extras": { "ringtone": "classic" }
2144        }"#;
2145        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2146        assert_eq!(parsed.envelope.schema_version, Some(2));
2147        let extras = parsed.envelope.extras.as_ref().expect("extras present");
2148        assert_eq!(extras["ringtone"], "classic");
2149    }
2150}