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    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the user
257    /// deleted this call at that time.
258    ///
259    /// Calls are otherwise immutable one-way pushes, and this is the
260    /// single exception: a delete has to reach the platform somehow, and
261    /// a hard `DELETE` can't sync under a "push the row" model — once
262    /// the row is gone there's nothing left to push. So a delete rides
263    /// as an ordinary upsert with this field set, exactly like
264    /// [`VoiceAccountRecord::deleted_at`].
265    ///
266    /// Where it differs from the account tombstone: **the platform
267    /// treats this one as sticky, not last-write-wins.** An account is
268    /// genuinely mutable, so it carries `updated_at` and conflicts
269    /// resolve on it; a call has no such field because delete is the
270    /// only mutation it has. The platform resolves the column
271    /// `COALESCE(existing, incoming)`, so once a call is deleted a
272    /// later sync of the same `source_id` can never revive it — which
273    /// also means a consumer must not expect to "undelete" by syncing
274    /// the row again with `None`.
275    ///
276    /// Deleting a call is not only a flag on the platform side: the
277    /// recording bytes are removed from object storage, the recording
278    /// and transcript rows are dropped, and any live share link is
279    /// revoked (it answers 410 thereafter). The tombstone row is
280    /// retained so a late-syncing device still learns about the delete
281    /// — read it via `include_deleted` on
282    /// [`VoiceCallsQuery`]. `GET /api/voice/calls/{sourceId}` returns
283    /// 404 for a deleted call rather than echoing the tombstone.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub deleted_at: Option<String>,
286    /// Version + forward-compat fields shared by every sync record.
287    /// Flattened so `schemaVersion` and `extras` sit at the top of
288    /// the JSON object alongside the other columns. See
289    /// [`SyncEnvelope`] and doc 21 §"Versioning and forward
290    /// compatibility".
291    #[serde(flatten, default)]
292    pub envelope: SyncEnvelope,
293}
294
295/// Query params for `GET /api/voice/calls`. All fields optional — the
296/// default returns the newest page.
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct VoiceCallsQuery {
300    /// Include soft-deleted tombstones in the response. Absent / false
301    /// returns only live calls — what a human-facing list wants. A
302    /// delta-syncing device sets this `true` to learn about deletes
303    /// made on another device or on the web, so it can reap its local
304    /// copy.
305    ///
306    /// Unlike [`VoiceAccountsQuery::include_deleted`] there is no
307    /// "restore a fresh device" use for this: a tombstoned call has had
308    /// its recording and transcript destroyed, so the only thing left
309    /// to learn from it is that it's gone.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub include_deleted: Option<bool>,
312    /// RFC 3339 cursor; rows with `end_at < before` are returned.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub before: Option<String>,
315    /// 1..=200. Server default is 50.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub limit: Option<u32>,
318}
319
320/// Marker for the `/api/voice/calls/{sync,list}` endpoint pair.
321///
322/// Use as a type parameter, never construct: `client.sync::<VoiceCalls>(&items)`.
323pub struct VoiceCalls;
324
325impl SyncEndpoint for VoiceCalls {
326    const RESOURCE: &'static str = "calls";
327    type Record = VoiceCallRecord;
328    type Query = VoiceCallsQuery;
329}
330
331impl HasSyncEnvelope for VoiceCallRecord {
332    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
333        &mut self.envelope
334    }
335}
336
337// ---- VoiceRecordings ------------------------------------------------------
338
339/// One per-call recording's metadata as it crosses the wire from the
340/// daemon up to the platform. The WAV bytes ride on a separate
341/// follow-up call ([`Client::upload_recording_bytes`]) so the
342/// idempotent metadata sync stays small and a flaky bytes upload
343/// doesn't force the daemon to re-ship the row.
344///
345/// Mirrors the daemon's `RecordingArtifact` (see
346/// `wavekat-voice/crates/wavekat-voice/src/recording.rs`) with one
347/// rename: the daemon's local id (`id`) ships as `source_id` because
348/// the platform allocates its own row id and treats the daemon-side
349/// UUID as the idempotency key (same convention as
350/// [`VoiceCallRecord`]).
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct VoiceRecordingRecord {
354    /// Daemon-generated UUID for this recording artifact. Upsert key
355    /// on the platform side.
356    pub source_id: String,
357    /// Daemon's `calls.id` — the call this recording belongs to.
358    /// The platform stores both so the /voice/calls history page can
359    /// link a call to its recording without a separate join table.
360    pub call_source_id: String,
361    /// Byte length of the WAV file the daemon will PUT in the follow-
362    /// up bytes call. The platform refuses a PUT whose body length
363    /// disagrees.
364    pub size_bytes: u64,
365    pub duration_ms: u64,
366    pub sample_rate: u32,
367    pub channels: u16,
368    /// RFC 3339 timestamp the daemon stamped on the artifact at
369    /// finalize time. Drives the platform's `/voice/recordings` GET
370    /// cursor.
371    pub created_at: String,
372    #[serde(flatten, default)]
373    pub envelope: SyncEnvelope,
374}
375
376/// Query params for `GET /api/voice/recordings`.
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct VoiceRecordingsQuery {
380    /// RFC 3339 cursor; rows with `created_at < before` are returned.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub before: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub limit: Option<u32>,
385}
386
387/// Marker for the `/api/voice/recordings/{sync,list}` endpoint pair.
388///
389/// The corresponding bytes-upload endpoint
390/// (`PUT /api/voice/recordings/{sourceId}/bytes`) is invoked via
391/// [`Client::upload_recording_bytes`] — it doesn't fit the
392/// `SyncEndpoint` mold (no batch, no JSON body) so it has its own
393/// inherent method on `Client`.
394pub struct VoiceRecordings;
395
396impl SyncEndpoint for VoiceRecordings {
397    const RESOURCE: &'static str = "recordings";
398    type Record = VoiceRecordingRecord;
399    type Query = VoiceRecordingsQuery;
400}
401
402impl HasSyncEnvelope for VoiceRecordingRecord {
403    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
404        &mut self.envelope
405    }
406}
407
408/// One item in the platform's response to
409/// `POST /api/voice/recordings/sync`. Lets the daemon learn the R2
410/// key the platform stamped (so a subsequent bytes PUT can target it)
411/// without re-deriving it, and check whether bytes have already
412/// landed on a prior cycle (so the daemon can mark the local row
413/// synced without re-uploading the WAV).
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(rename_all = "camelCase")]
416pub struct VoiceRecordingSyncItem {
417    pub source_id: String,
418    pub r2_key: String,
419    pub bytes_uploaded: bool,
420}
421
422/// Full response from `POST /api/voice/recordings/sync`. Superset of
423/// the generic [`crate::SyncResponse`] — see [`Client::sync_recordings`].
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct VoiceRecordingsSyncResponse {
427    pub accepted: u32,
428    pub skipped: u32,
429    pub items: Vec<VoiceRecordingSyncItem>,
430}
431
432// ---- VoiceTranscripts -----------------------------------------------------
433
434/// Wire-stable transcript channel tag. Matches the daemon's
435/// `TranscriptChannelLabel` and `events::TranscriptChannel`.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "snake_case")]
438pub enum VoiceTranscriptChannel {
439    /// Local mic audio — what the user said.
440    Local,
441    /// Received RTP audio — what the remote party said.
442    Remote,
443}
444
445/// One ASR transcript segment ("final" in wavekat-asr parlance) as it
446/// crosses the wire. Each segment is a row on the daemon side
447/// (`transcripts` table); the daemon batches a slice of them per
448/// upload and the platform upserts per (user_id, source_id).
449#[derive(Debug, Clone, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct VoiceTranscriptRecord {
452    /// Daemon-side row id, formatted as text (the column is an
453    /// autoincrement integer on SQLite). Stable per (call, segment)
454    /// so re-shipping converges.
455    pub source_id: String,
456    /// Daemon's `calls.id` — the call this segment belongs to.
457    pub call_source_id: String,
458    pub channel: VoiceTranscriptChannel,
459    /// Start of the segment in milliseconds relative to the start of
460    /// the call's audio stream (not wall-clock).
461    pub ts_ms: i64,
462    /// End of the segment, same reference frame as `ts_ms`.
463    pub end_ms: i64,
464    /// Recognised text. Free-form; the platform stores it verbatim.
465    pub text: String,
466    #[serde(flatten, default)]
467    pub envelope: SyncEnvelope,
468}
469
470/// Query params for `GET /api/voice/transcripts` — required
471/// `call_source_id` (the endpoint refuses a flat list).
472#[derive(Debug, Clone, Default, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct VoiceTranscriptsQuery {
475    pub call_source_id: String,
476}
477
478/// Marker for the `/api/voice/transcripts/{sync,list}` endpoint pair.
479pub struct VoiceTranscripts;
480
481impl SyncEndpoint for VoiceTranscripts {
482    const RESOURCE: &'static str = "transcripts";
483    type Record = VoiceTranscriptRecord;
484    type Query = VoiceTranscriptsQuery;
485}
486
487impl HasSyncEnvelope for VoiceTranscriptRecord {
488    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
489        &mut self.envelope
490    }
491}
492
493// ---- VoiceAccounts --------------------------------------------------------
494
495/// SIP transport for a synced account line. Wire-stable snake_case;
496/// mirrors the daemon's `TransportKind`.
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(rename_all = "snake_case")]
499pub enum VoiceTransport {
500    Udp,
501    Tcp,
502}
503
504/// One SIP account line's *configuration* as it crosses the wire from a
505/// device up to the platform and back down to another device
506/// (`wavekat-voice/docs/40-account-config-sync.md`).
507///
508/// Unlike calls / recordings / transcripts — which are immutable,
509/// one-way pushes — account config is **mutable and bidirectional**: a
510/// line is edited, toggled, renamed, and deleted, and those changes must
511/// restore onto a second device. The same idempotent
512/// `(user_id, source_id)` upsert that [`Client::sync`] performs carries
513/// every kind of change here; a *delete* is a soft-delete that rides as
514/// an upsert with `deleted_at` set, because a hard DELETE can't sync
515/// under a "push the row" model — once the row is gone there's nothing
516/// left to push.
517///
518/// **No secret field, by construction.** The SIP password never appears
519/// on this wire. Config sync (policy levels 1–2) keeps the credential
520/// device-local, and the end-to-end-encrypted secret path (level 3)
521/// ships its ciphertext through a *separate* opaque resource, never as a
522/// field here. Omitting it means level 3 can't be populated by accident
523/// before it exists.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525#[serde(rename_all = "camelCase")]
526pub struct VoiceAccountRecord {
527    /// Daemon-side account UUID (`accounts.id`). The platform's
528    /// `(user_id, source_id)` upsert key — re-syncing the same id
529    /// updates the row in place (mutable), unlike the immutable
530    /// resources where a re-sync is a no-op.
531    pub source_id: String,
532    /// Whether the line registers on daemon boot. Pausing a line is a
533    /// portable preference, so it rides along.
534    pub enabled: bool,
535    pub display_name: String,
536    pub username: String,
537    pub domain: String,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub auth_username: Option<String>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub server: Option<String>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub port: Option<u16>,
544    pub transport: VoiceTransport,
545    pub register_expires: u32,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub keepalive_secs: Option<u32>,
548    /// Record-disclosure beep toggle — a column on the account row, so
549    /// it rides along for free (the account-portable taxonomy in doc 40).
550    pub disclosure_enabled: bool,
551    /// RFC 3339 last-modification time — the **last-write-wins key**. On
552    /// conflict the platform (and a pulling client) keep the copy with
553    /// the later `updated_at`. Whole-row LWW for v1; per-field merge is
554    /// deferred until users actually report lost edits (doc 40).
555    pub updated_at: String,
556    /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the line
557    /// was deleted on some device at that time. A tombstone syncs like
558    /// any other mutation so the delete propagates to other devices,
559    /// then is reaped locally once confirmed. The platform retains
560    /// tombstones so a late-syncing device still learns about the delete.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub deleted_at: Option<String>,
563    /// Version + forward-compat fields shared by every sync record.
564    #[serde(flatten, default)]
565    pub envelope: SyncEnvelope,
566}
567
568/// Query params for `GET /api/voice/accounts`. All fields optional.
569#[derive(Debug, Clone, Default, Serialize, Deserialize)]
570#[serde(rename_all = "camelCase")]
571pub struct VoiceAccountsQuery {
572    /// Include soft-deleted tombstones in the response. Absent / false
573    /// returns only live lines — the restore-grade pull a fresh device
574    /// wants. A delta-syncing device sets this `true` to also learn
575    /// about deletes made elsewhere (doc 40).
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub include_deleted: Option<bool>,
578}
579
580/// Marker for the `/api/voice/accounts/{sync,list}` endpoint pair.
581///
582/// Accounts are the first *mutable, bidirectional* sync resource, but
583/// the wire shape is the same idempotent upsert the immutable resources
584/// use — the [`SyncResponse::skipped`](crate::sync::SyncResponse) field
585/// was reserved for exactly this case — so no new HTTP plumbing is
586/// needed: `client.sync::<VoiceAccounts>(&items)` uploads (including
587/// tombstones), `client.list::<VoiceAccounts>(&query)` pulls.
588pub struct VoiceAccounts;
589
590impl SyncEndpoint for VoiceAccounts {
591    const RESOURCE: &'static str = "accounts";
592    type Record = VoiceAccountRecord;
593    type Query = VoiceAccountsQuery;
594}
595
596impl HasSyncEnvelope for VoiceAccountRecord {
597    fn envelope_mut(&mut self) -> &mut SyncEnvelope {
598        &mut self.envelope
599    }
600}
601
602// ---- VoiceFlows (published pull) -------------------------------------------
603//
604// The daemon-facing pull leg of the call-flow ("Receptionist") system —
605// `wavekat-voice/docs/48-ivr-call-flows.md`'s control-plane split. Flows
606// are *authored* on the platform (drafts, publish gate, version
607// history); the daemon only ever reads the published snapshots, caches
608// them locally, and runs them offline. There is no upload direction, so
609// this is not a `SyncEndpoint` (that trait models the `{resource}/sync`
610// + list pair): it's a single typed GET, like the share commands above.
611
612/// One published call-flow snapshot as served by
613/// `GET /api/voice/flows/published`: the latest published version of a
614/// flow the bearer authored. The YAML carries the platform-stamped
615/// `id`/`name`/`version` and is served verbatim — the daemon re-parses
616/// and re-validates it on load rather than trusting the wire.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618#[serde(rename_all = "camelCase")]
619pub struct VoiceFlowRecord {
620    /// Platform-assigned flow id (`flow_…`), stable across versions.
621    pub id: String,
622    pub name: String,
623    /// Latest published version number (1-based, bumps on publish).
624    pub version: u32,
625    /// The immutable published document, verbatim.
626    pub yaml: String,
627    /// RFC 3339 time this version was published.
628    pub published_at: String,
629}
630
631/// Query params for `GET /api/voice/flows/published`. Cursor-paginated
632/// by flow id ascending; pass the previous page's `next_after` until it
633/// comes back `None` to collect the full set. The full set is what the
634/// daemon's reconcile wants — a cached flow absent from a complete pull
635/// was deleted on the platform.
636#[derive(Debug, Clone, Default, Serialize, Deserialize)]
637#[serde(rename_all = "camelCase")]
638pub struct VoiceFlowsQuery {
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub after: Option<String>,
641    /// Page size, server-capped at 100. `None` = server default (50).
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub limit: Option<u32>,
644    /// The document versions this caller's flow engine can run —
645    /// `wavekat_flow::SUPPORTED_SCHEMA_VERSIONS`, comma-separated
646    /// ascending ("1,2"). The platform withholds documents in any other
647    /// version rather than serving one the caller would fail to parse.
648    ///
649    /// **Send it.** `None` does not mean "anything goes": the platform
650    /// reads a missing value as version 1 only, because this parameter
651    /// arrived alongside version 2 and a caller that omits it is an
652    /// older build. A client that can run a newer version and stays
653    /// quiet silently loses those flows.
654    //
655    // Explicitly renamed: the struct is camelCase overall, but this
656    // route's query parameter is `schema_versions`, and a silently
657    // camelCased key would be ignored by the server — which reads
658    // exactly like a platform that has no such flows.
659    #[serde(
660        rename = "schema_versions",
661        default,
662        skip_serializing_if = "Option::is_none"
663    )]
664    pub schema_versions: Option<String>,
665}
666
667/// One page of published flow snapshots.
668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669#[serde(rename_all = "camelCase")]
670pub struct VoiceFlowsPage {
671    pub items: Vec<VoiceFlowRecord>,
672    /// Cursor for the next page; `None` = end of the set.
673    #[serde(default)]
674    pub next_after: Option<String>,
675}
676
677/// One frozen audio asset of a published flow version, as served by
678/// `GET /api/voice/flows/{id}/versions/{version}/assets` (wavekat-platform
679/// docs 16/17). The bytes were copied into a version-owned R2 object at
680/// publish time and never change, so `content_hash` identifies them
681/// exactly — the daemon diffs its local cache against it rather than
682/// trusting a bare filename, because the *same* `ref` can carry different
683/// bytes across two versions of the same flow.
684#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685#[serde(rename_all = "camelCase")]
686pub struct VoiceFlowVersionAsset {
687    /// The `vprompt_…` reference exactly as it appears in the flow YAML.
688    #[serde(rename = "ref")]
689    pub asset_ref: String,
690    /// Source telephony format the clip was frozen as (`ulaw_8000`,
691    /// `pcm_16000`, `mp3`, …); the container is WAV unless `mp3`.
692    pub format: String,
693    /// Size of the frozen bytes.
694    pub byte_size: u64,
695    /// Clip duration if the platform knew it at freeze time.
696    #[serde(default)]
697    pub duration_ms: Option<u64>,
698    /// sha256 of the frozen bytes — the cache's content key.
699    pub content_hash: String,
700}
701
702/// The frozen-asset manifest for one published version. Not paginated:
703/// a flow's asset count is bounded by its node count (a phone tree is
704/// tens of clips), so the platform returns them all in one response.
705#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
706#[serde(rename_all = "camelCase")]
707pub struct VoiceFlowAssetsPage {
708    pub assets: Vec<VoiceFlowVersionAsset>,
709}
710
711// ---- System flows (public, unauthenticated) --------------------------------
712//
713// The platform's curated set of ready-made call flows, served to every
714// device — signed in or not — and cached locally for offline use. These
715// are distinct from a user's own authored flows (the published-flows
716// endpoint above) and are read-only to clients; the flows are authored
717// on the platform and have no upload direction. Keyed by language tier
718// and published schema version (spec §5, doc 48 amendment 2026-08-27).
719
720/// One system (ready-made) call-flow record as served by the unauthenticated
721/// `GET /api/voice/flows/system?language=…&schema_versions=…` endpoint.
722/// Public by design — a signed-out device lists the catalogue and may
723/// preview, cache, and arm from it (gated by entitlement at arming time).
724///
725/// `description`, `publishedAt`, and `systemTags` may be absent on older
726/// rows or when the platform withheld them; all are optional.
727///
728/// A consumer has to be able to *name* this type — to map a record into its
729/// own cache row, or to build one in a fixture — not merely receive it by
730/// inference from [`Client::system_flows`]. This example is that guarantee:
731/// a doctest compiles as a downstream crate, so it fails if the type ever
732/// stops being re-exported from the crate root. The unit tests below cannot
733/// catch that, because inside the crate the private `voice` module is always
734/// in scope — which is exactly how 0.0.26 through 0.0.28 shipped these two
735/// types unreachable.
736///
737/// ```
738/// use wavekat_platform_client::{VoiceSystemFlowRecord, VoiceSystemFlowsPage};
739///
740/// let page: VoiceSystemFlowsPage = serde_json::from_str(
741///     r#"{"flows":[{"id":"flow_voicemail","name":"Voicemail","description":"",
742///          "language":"en","version":2,"yaml":"schema_version: 1\n",
743///          "publishedAt":null,"access":"open","systemTags":["system"]}]}"#,
744/// )
745/// .unwrap();
746/// let first: &VoiceSystemFlowRecord = &page.flows[0];
747/// assert_eq!(first.access, "open");
748/// ```
749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(rename_all = "camelCase")]
751pub struct VoiceSystemFlowRecord {
752    /// Platform-assigned flow id (`flow_…`), stable across versions.
753    pub id: String,
754    pub name: String,
755    /// Optional short description of what the flow does.
756    #[serde(default)]
757    pub description: String,
758    /// BCP-47-ish language tag — the tier this flow was selected in by
759    /// the device's language preference.
760    pub language: String,
761    /// Published version number (1-based).
762    pub version: u32,
763    /// The immutable published YAML document, verbatim.
764    pub yaml: String,
765    /// When this version was published, **verbatim from the platform's D1
766    /// column** — which defaults to SQLite `CURRENT_TIMESTAMP` and so is
767    /// `"YYYY-MM-DD HH:MM:SS"` in UTC, *not* RFC 3339 (space separator, no
768    /// offset). Some rows do carry RFC 3339. Consumers must accept **both**:
769    /// a strict RFC 3339 parse is how every pulled flow once rendered as
770    /// "Updated Jan 1, 1970" in the desktop client. Absent on older rows.
771    #[serde(default)]
772    pub published_at: Option<String>,
773    /// Platform-resolved arming rung. One of `"open"`, `"account"`, `"pro"`,
774    /// or an unknown value (forward-compat for new platform rungs). Unknown
775    /// values are treated as the strictest known rung at arm time.
776    pub access: String,
777    /// Raw platform tags, preserved verbatim so a future feature can read
778    /// a new tag without a daemon release.
779    #[serde(default)]
780    pub system_tags: Vec<String>,
781}
782
783/// One page of system flows as served by
784/// `GET /api/voice/flows/system?language=…&schema_versions=…`.
785#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
786#[serde(rename_all = "camelCase")]
787pub struct VoiceSystemFlowsPage {
788    pub flows: Vec<VoiceSystemFlowRecord>,
789}
790
791impl Client {
792    /// `GET /api/voice/flows/published` — one page of the caller's
793    /// published flow snapshots (latest version each). Strictly
794    /// creator-scoped server-side; never returns another user's flows.
795    pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
796        self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
797            .await
798    }
799
800    /// `GET /api/voice/flows/{id}/versions/{version}/assets` — the frozen
801    /// audio manifest for one published version (docs 16/17). Flow-scoped
802    /// server-side: a version of a flow the caller doesn't own is a 404,
803    /// never another user's assets. An existing, visible version with no
804    /// generated audio returns an empty manifest.
805    pub async fn flow_version_assets(
806        &self,
807        flow_id: &str,
808        version: u32,
809    ) -> Result<VoiceFlowAssetsPage> {
810        let path = format!("/api/voice/flows/{flow_id}/versions/{version}/assets");
811        self.get_json::<VoiceFlowAssetsPage>(&path).await
812    }
813
814    /// `GET /api/voice/flows/{id}/versions/{version}/assets/{ref}/bytes` —
815    /// the immutable frozen copy of one clip, served from the version's own
816    /// asset set (never the mutable library). Returned in memory because a
817    /// clip is tens of KB and the daemon writes it atomically into its
818    /// on-disk cache; same flow-scoped 404 as the manifest.
819    pub async fn flow_version_asset_bytes(
820        &self,
821        flow_id: &str,
822        version: u32,
823        asset_ref: &str,
824    ) -> Result<Vec<u8>> {
825        let path =
826            format!("/api/voice/flows/{flow_id}/versions/{version}/assets/{asset_ref}/bytes");
827        self.get_bytes(&path).await
828    }
829
830    /// `GET /api/voice/flows/system?language=…&schema_versions=…` — the
831    /// curated system (ready-made) flow catalogue, tier-cut by language and
832    /// filterable by supported schema versions. Public by design — a
833    /// signed-out device lists and caches the catalogue. No bearer auth
834    /// on purpose; the endpoint is available before any sign-in.
835    ///
836    /// `language` is optional (the platform lists all when absent); pass
837    /// `None` to omit it. `schema_versions` is a comma-separated ascending
838    /// list (`"1,2"`) and is always sent — the platform reads silence as
839    /// "v1 only", same warning as [`VoiceFlowsQuery::schema_versions`].
840    pub async fn system_flows(
841        base_url: &str,
842        language: Option<&str>,
843        schema_versions: &str,
844    ) -> Result<VoiceSystemFlowsPage> {
845        let language_owned;
846        let mut query: Vec<(&str, &str)> = vec![("schema_versions", schema_versions)];
847        if let Some(lang) = language {
848            language_owned = lang.to_string();
849            query.push(("language", &language_owned));
850        }
851        Self::get_public_json::<VoiceSystemFlowsPage>(base_url, "/api/voice/flows/system", &query)
852            .await
853    }
854
855    /// `GET /api/voice/flows/system/{id}/versions/{version}/assets` — the
856    /// frozen audio manifest for one system flow version. Public by design.
857    /// Returns an empty manifest if the version has no generated audio.
858    ///
859    /// Reuses [`VoiceFlowAssetsPage`], which is the same wire shape as the
860    /// gated manifest for owned flows.
861    pub async fn system_flow_version_assets(
862        base_url: &str,
863        flow_id: &str,
864        version: u32,
865    ) -> Result<VoiceFlowAssetsPage> {
866        let path = format!("/api/voice/flows/system/{flow_id}/versions/{version}/assets");
867        Self::get_public_json::<VoiceFlowAssetsPage>(base_url, &path, &[]).await
868    }
869
870    /// `GET /api/voice/flows/system/{id}/versions/{version}/assets/{ref}/bytes`
871    /// — one clip from a
872    /// system flow's frozen asset set. Public by design — a signed-out
873    /// device fetches clips for offline preview and caching. Returned in
874    /// memory because a clip is tens of KB; same atomicity and offline-safe
875    /// guarantees as the gated owned-flow asset fetch.
876    pub async fn system_flow_version_asset_bytes(
877        base_url: &str,
878        flow_id: &str,
879        version: u32,
880        asset_ref: &str,
881    ) -> Result<Vec<u8>> {
882        let path = format!(
883            "/api/voice/flows/system/{flow_id}/versions/{version}/assets/{asset_ref}/bytes"
884        );
885        Self::get_public_bytes(base_url, &path).await
886    }
887}
888
889// ---- Booking (mid-call, synchronous) ---------------------------------------
890//
891// The action plane of wavekat-platform's docs/30: a `book` step asking
892// "when is this business free?" and then "put the caller in at this
893// time", with the caller on the line.
894//
895// Unlike every other endpoint in this file, these are **synchronous and
896// in-call**. Nothing here is queued, batched or retried: a person is
897// waiting, so the platform answers within seconds or answers
898// `unavailable`, and the flow takes its fallback exit. Callers should
899// give these a short timeout of their own and treat expiry the same way
900// they treat `unavailable`.
901//
902// The calendar credential never reaches this crate. The platform holds
903// the connection and answers in times and outcomes — which is what makes
904// booking a pair of platform calls rather than a Google client in every
905// daemon.
906//
907// Wire note: these routes use `snake_case` bodies, unlike the camelCase
908// sync resources above, so these types carry no `rename_all`.
909
910/// One open window in a business's week, `"HH:MM"` 24-hour local time —
911/// the same shape the flow document's `hours`/`book` steps carry.
912#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
913pub struct BookingTimeRange {
914    pub open: String,
915    pub close: String,
916}
917
918/// Open windows per weekday. A missing or empty day is closed.
919#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
920pub struct BookingSchedule {
921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
922    pub mon: Vec<BookingTimeRange>,
923    #[serde(default, skip_serializing_if = "Vec::is_empty")]
924    pub tue: Vec<BookingTimeRange>,
925    #[serde(default, skip_serializing_if = "Vec::is_empty")]
926    pub wed: Vec<BookingTimeRange>,
927    #[serde(default, skip_serializing_if = "Vec::is_empty")]
928    pub thu: Vec<BookingTimeRange>,
929    #[serde(default, skip_serializing_if = "Vec::is_empty")]
930    pub fri: Vec<BookingTimeRange>,
931    #[serde(default, skip_serializing_if = "Vec::is_empty")]
932    pub sat: Vec<BookingTimeRange>,
933    #[serde(default, skip_serializing_if = "Vec::is_empty")]
934    pub sun: Vec<BookingTimeRange>,
935}
936
937/// A single-date override of the weekly schedule (a holiday, or special
938/// hours).
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
940pub struct BookingException {
941    /// `"YYYY-MM-DD"` in the schedule's own timezone.
942    pub date: String,
943    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
944    pub closed: bool,
945    #[serde(default, skip_serializing_if = "Vec::is_empty")]
946    pub ranges: Vec<BookingTimeRange>,
947}
948
949/// Body of `POST /api/voice/booking/slots`.
950///
951/// Everything except `source_id` comes straight off the flow document's
952/// `book` step; the platform holds no per-node configuration of its own.
953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
954pub struct BookingSlotsRequest {
955    /// The call this offer belongs to (`voice_calls.source_id`). Slots
956    /// are held against it, which is what stops a caller being blocked
957    /// by their own offers — and what stops a second caller being
958    /// offered the same time.
959    pub source_id: String,
960    pub duration_mins: u32,
961    #[serde(default)]
962    pub buffer_mins: u32,
963    #[serde(default)]
964    pub lead_mins: u32,
965    #[serde(default)]
966    pub horizon_days: u32,
967    pub schedule: BookingSchedule,
968    /// IANA zone the schedule is written in.
969    pub timezone: String,
970    #[serde(default, skip_serializing_if = "Vec::is_empty")]
971    pub exceptions: Vec<BookingException>,
972    /// How many times to offer. The answer may be shorter, never longer.
973    pub limit: u32,
974}
975
976/// One offerable appointment, as absolute RFC 3339 instants.
977#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
978pub struct BookingSlot {
979    pub start: String,
980    pub end: String,
981}
982
983/// Answer to `POST /api/voice/booking/slots`.
984///
985/// `slots` empty is a real answer — the calendar is full, or the window
986/// closed — and not an error: the flow takes its no-slots exit.
987#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
988pub struct BookingSlotsResponse {
989    #[serde(default)]
990    pub slots: Vec<BookingSlot>,
991    /// The zone the times should be *spoken* in — the business's, echoed
992    /// back so the caller isn't told a time in the server's zone.
993    #[serde(default)]
994    pub timezone: String,
995    /// Set when the platform could not read the calendar at all
996    /// (`"unavailable"`); `slots` is then empty and the reason is for
997    /// logs, never for a caller.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub status: Option<String>,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub reason: Option<String>,
1002}
1003
1004/// Body of `POST /api/voice/booking/book`.
1005///
1006/// Idempotent on `source_id`: a retried request for a call that already
1007/// has an appointment answers `booked` with the existing event's start,
1008/// without touching the calendar. A timed-out request is therefore safe
1009/// to repeat.
1010#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1011pub struct BookingBookRequest {
1012    pub source_id: String,
1013    /// One of the `start`s `/slots` handed back, verbatim.
1014    pub start: String,
1015    pub duration_mins: u32,
1016    pub timezone: String,
1017    /// Who is booking, for the calendar entry. Empty when the call
1018    /// carried no caller id.
1019    #[serde(default)]
1020    pub caller_number: String,
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub caller_name: Option<String>,
1023}
1024
1025/// Answer to `POST /api/voice/booking/book`.
1026///
1027/// Three outcomes, and the flow does something different with each:
1028/// `booked` continues, `slot_taken` can offer again, `unavailable` falls
1029/// back. Left as a string rather than an enum so a status added later
1030/// deserializes instead of failing the call.
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032pub struct BookingBookResponse {
1033    pub status: String,
1034    /// Present on `booked` — the instant the appointment actually
1035    /// starts, which on an idempotent retry is the *existing* event's
1036    /// start and not necessarily the one that was asked for.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub start: Option<String>,
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub reason: Option<String>,
1041}
1042
1043impl Client {
1044    /// `POST /api/voice/booking/slots` — when is this business free?
1045    ///
1046    /// Writes as well as reads: every time it returns is held for
1047    /// `source_id` for a couple of minutes, so a second caller is not
1048    /// offered it while this one is still deciding. Re-offering the same
1049    /// call refreshes its own holds rather than colliding with them.
1050    pub async fn booking_slots(
1051        &self,
1052        request: &BookingSlotsRequest,
1053    ) -> Result<BookingSlotsResponse> {
1054        self.post_json::<BookingSlotsResponse, _>("/api/voice/booking/slots", request)
1055            .await
1056    }
1057
1058    /// `POST /api/voice/booking/book` — put the caller in at this time.
1059    pub async fn booking_book(&self, request: &BookingBookRequest) -> Result<BookingBookResponse> {
1060        self.post_json::<BookingBookResponse, _>("/api/voice/booking/book", request)
1061            .await
1062    }
1063}
1064
1065// ---- Anonymous install heartbeat ------------------------------------------
1066//
1067// A first-run / per-launch ping the desktop daemon fires *before* (and
1068// independently of) any platform sign-in, so the platform can count
1069// installs and track version / OS adoption for users who never sign in.
1070// It hits the public, unauthenticated `POST /api/voice/installs/heartbeat`
1071// and upserts a row keyed by `install_id` alone (no user) — distinct
1072// from the authenticated `voice_clients` heartbeat, which is keyed by
1073// `(user, install_id)`.
1074//
1075// The environment fields (os / os_version / arch / locale) are gathered
1076// *here*, inside the client crate, rather than on the consumer side:
1077// the daemon only owns the two values this crate genuinely cannot
1078// discover — the persisted `install_id` and its own app version.
1079
1080/// Best-effort snapshot of the host environment, detected at call time.
1081/// Every field is best-effort; a probe that fails contributes `None`
1082/// (or, for the always-available `os` / `arch`, the compile-time
1083/// target) rather than failing the heartbeat.
1084#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct SystemInfo {
1086    /// `std::env::consts::OS` — `"macos"`, `"windows"`, `"linux"`, …
1087    pub os: String,
1088    /// Human OS version, e.g. `"15.5.0"`. `None` when the OS probe
1089    /// can't determine it.
1090    pub os_version: Option<String>,
1091    /// `std::env::consts::ARCH` — `"aarch64"`, `"x86_64"`, …
1092    pub arch: String,
1093    /// BCP-47 system locale, e.g. `"en-NZ"`. `None` when unset /
1094    /// undetectable (common for GUI-launched apps on some platforms).
1095    pub locale: Option<String>,
1096}
1097
1098impl SystemInfo {
1099    /// Probe the current host. Cheap enough to call per heartbeat; we
1100    /// don't cache so a locale change between launches is reflected.
1101    pub fn detect() -> Self {
1102        let os_version = match os_info::get().version() {
1103            os_info::Version::Unknown => None,
1104            v => Some(v.to_string()),
1105        };
1106        SystemInfo {
1107            os: std::env::consts::OS.to_string(),
1108            os_version,
1109            arch: std::env::consts::ARCH.to_string(),
1110            locale: sys_locale::get_locale(),
1111        }
1112    }
1113}
1114
1115/// Body of `POST /api/voice/installs/heartbeat`. The daemon supplies
1116/// `install_id` + `app_version`; [`Client::install_heartbeat`] fills the
1117/// environment fields from [`SystemInfo::detect`].
1118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1119#[serde(rename_all = "camelCase")]
1120pub struct InstallHeartbeatRequest {
1121    /// The daemon's persisted install UUID — the platform's upsert key.
1122    pub install_id: String,
1123    /// WaveKat Voice's own version (`env!("CARGO_PKG_VERSION")` on the
1124    /// daemon side) — *not* this crate's version.
1125    pub app_version: String,
1126    pub os: String,
1127    #[serde(default, skip_serializing_if = "Option::is_none")]
1128    pub os_version: Option<String>,
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub arch: Option<String>,
1131    #[serde(default, skip_serializing_if = "Option::is_none")]
1132    pub locale: Option<String>,
1133    /// How this copy was obtained — `"direct"` for a plain download,
1134    /// `"mas"` for the sandboxed Mac App Store build. Unlike every other
1135    /// field here it is **not** detectable: the two macOS builds share a
1136    /// bundle id and a version, and the binary is identical, so only the
1137    /// consumer knows which one it is shipping inside. Hence a caller
1138    /// argument rather than part of [`SystemInfo`].
1139    ///
1140    /// Free text by contract, not an enum: the platform stores whatever
1141    /// arrives so a new distribution can ship without a server release.
1142    /// `None` when the consumer has nothing meaningful to say (a source
1143    /// build, a package this crate has never heard of) — omitted from
1144    /// the body entirely rather than sent as null.
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub distribution: Option<String>,
1147    /// Fleet-admin fields (build provenance, update-channel and
1148    /// updater state, native arch, process start time). Flattened onto
1149    /// the wire so the body stays flat JSON even though the daemon
1150    /// builds one value; see [`InstallHeartbeatFleet`].
1151    #[serde(flatten, default)]
1152    pub fleet: InstallHeartbeatFleet,
1153}
1154
1155/// Optional fleet-admin fields on the install heartbeat, grouped so the
1156/// daemon can build (and the platform's fleet-admin view can read) one
1157/// value rather than ten loose arguments. Flattened onto
1158/// [`InstallHeartbeatRequest`] via `#[serde(flatten)]`, so on the wire
1159/// these fields sit alongside `installId` / `appVersion` / … with no
1160/// nesting.
1161///
1162/// Every field is optional and additive: an older daemon that never
1163/// sets them sends a body identical to the one before this struct
1164/// existed (an all-`None` `InstallHeartbeatFleet` serializes to no
1165/// extra keys), and a platform that hasn't deployed the corresponding
1166/// migration yet simply stores nulls. Read by `wavekat-platform`'s
1167/// fleet-admin view (docs/45) — keep every field optional so this
1168/// remains safe to send against an older platform and safe to omit
1169/// from an older daemon.
1170#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1171#[serde(rename_all = "camelCase")]
1172pub struct InstallHeartbeatFleet {
1173    /// The git SHA the daemon binary was built from.
1174    #[serde(default, skip_serializing_if = "Option::is_none")]
1175    pub build_sha: Option<String>,
1176    /// How this copy was obtained, at a finer grain than
1177    /// [`InstallHeartbeatRequest::distribution`] — e.g. `"mas"`,
1178    /// `"msstore"`, `"snap"`, `"appimage"`, `"deb"`, `"direct"`,
1179    /// `"dev"`. Free text by contract, not an enum: the platform
1180    /// stores whatever arrives so a new install source can ship
1181    /// without a server release.
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub install_source: Option<String>,
1184    /// Which release channel this build tracks — `"stable"` or
1185    /// `"beta"`.
1186    #[serde(default, skip_serializing_if = "Option::is_none")]
1187    pub update_channel: Option<String>,
1188    /// Whether the daemon's self-updater is active. `false` where the
1189    /// updater is inert because the platform's own store owns updates
1190    /// instead (Mac App Store, Microsoft Store) or the build is
1191    /// unpackaged.
1192    #[serde(default, skip_serializing_if = "Option::is_none")]
1193    pub updater_enabled: Option<bool>,
1194    /// The self-updater's current state machine status — `"idle"`,
1195    /// `"checking"`, `"available"`, `"downloading"`, `"downloaded"`,
1196    /// `"not-available"`, or `"error"`.
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub updater_status: Option<String>,
1199    /// The version the updater has staged or is offering, when there
1200    /// is one pending.
1201    #[serde(default, skip_serializing_if = "Option::is_none")]
1202    pub updater_version: Option<String>,
1203    /// ISO-8601 timestamp of the updater's last check.
1204    #[serde(default, skip_serializing_if = "Option::is_none")]
1205    pub updater_checked_at: Option<String>,
1206    /// The updater's last error message, if any. At most 256
1207    /// characters — the caller truncates before sending.
1208    #[serde(default, skip_serializing_if = "Option::is_none")]
1209    pub updater_error: Option<String>,
1210    /// `os.machine()` — the host's native architecture. Differs from
1211    /// `arch` when the process is running under translation (e.g.
1212    /// Rosetta on Apple Silicon).
1213    #[serde(default, skip_serializing_if = "Option::is_none")]
1214    pub native_arch: Option<String>,
1215    /// ISO-8601 timestamp of when the daemon process started.
1216    #[serde(default, skip_serializing_if = "Option::is_none")]
1217    pub started_at: Option<String>,
1218}
1219
1220/// The platform's view of an install row, echoed back from a heartbeat.
1221#[derive(Debug, Clone, Serialize, Deserialize)]
1222#[serde(rename_all = "camelCase")]
1223pub struct InstallHeartbeatResponse {
1224    pub id: String,
1225    pub install_id: String,
1226    pub app_version: String,
1227    pub os: String,
1228    pub os_version: Option<String>,
1229    pub arch: Option<String>,
1230    pub locale: Option<String>,
1231    /// Echoed back. `#[serde(default)]` because a platform deployed
1232    /// before this field existed omits the key rather than sending null,
1233    /// and a heartbeat must not fail to parse against an older server.
1234    #[serde(default)]
1235    pub distribution: Option<String>,
1236    pub first_seen_at: String,
1237    pub last_seen_at: String,
1238}
1239
1240impl Client {
1241    /// `POST /api/voice/installs/heartbeat` — the anonymous, no-auth
1242    /// first-run install ping. Detects the host environment internally
1243    /// and posts it alongside the caller-supplied `install_id` +
1244    /// `app_version`. Associated (not a method) because the endpoint is
1245    /// unauthenticated — there's no token, and at first run there's no
1246    /// signed-in `Client` to hang it off of.
1247    ///
1248    /// Though unauthenticated, the request is **signed** with the release
1249    /// credential `cred` (a per-version Ed25519 key + master-issued
1250    /// certificate the consumer bakes in at build time) so the platform
1251    /// can verify it came from a genuine release and reject forged or
1252    /// replayed pings — see [`Client::post_public_signed_json`] and
1253    /// [`crate::sign`]. The platform needs only the master *public* key to
1254    /// verify.
1255    ///
1256    /// `base_url` is the platform base (e.g. `https://platform.wavekat.com`).
1257    ///
1258    /// `distribution` says how this copy was obtained (`"direct"`,
1259    /// `"mas"`, …). It is the one field this call can't detect for
1260    /// itself — see [`InstallHeartbeatRequest::distribution`] — so pass
1261    /// `None` if the consumer has nothing meaningful to say.
1262    ///
1263    /// Sends no fleet-admin fields (see [`InstallHeartbeatFleet`]) — a
1264    /// thin wrapper over [`Client::install_heartbeat_with`] for
1265    /// callers that don't have them. Consumers that do should call
1266    /// [`Client::install_heartbeat_with`] directly instead.
1267    pub async fn install_heartbeat(
1268        base_url: &str,
1269        install_id: &str,
1270        app_version: &str,
1271        distribution: Option<&str>,
1272        cred: &ReleaseCredential,
1273    ) -> Result<InstallHeartbeatResponse> {
1274        Client::install_heartbeat_with(
1275            base_url,
1276            install_id,
1277            app_version,
1278            distribution,
1279            cred,
1280            InstallHeartbeatFleet::default(),
1281        )
1282        .await
1283    }
1284
1285    /// As [`Client::install_heartbeat`], but also takes the
1286    /// fleet-admin fields (build provenance, update-channel and
1287    /// updater state, native arch, process start time) that a
1288    /// fleet-aware daemon can supply — see [`InstallHeartbeatFleet`].
1289    /// Pass `InstallHeartbeatFleet::default()` for a caller with
1290    /// nothing to report; [`Client::install_heartbeat`] does exactly
1291    /// that.
1292    pub async fn install_heartbeat_with(
1293        base_url: &str,
1294        install_id: &str,
1295        app_version: &str,
1296        distribution: Option<&str>,
1297        cred: &ReleaseCredential,
1298        fleet: InstallHeartbeatFleet,
1299    ) -> Result<InstallHeartbeatResponse> {
1300        let sys = SystemInfo::detect();
1301        let body = InstallHeartbeatRequest {
1302            install_id: install_id.to_string(),
1303            app_version: app_version.to_string(),
1304            os: sys.os,
1305            os_version: sys.os_version,
1306            arch: Some(sys.arch),
1307            locale: sys.locale,
1308            distribution: distribution.map(str::to_string),
1309            fleet,
1310        };
1311        Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
1312            base_url,
1313            "/api/voice/installs/heartbeat",
1314            &body,
1315            cred,
1316        )
1317        .await
1318    }
1319}
1320
1321// ---- Client surface for recordings ----------------------------------------
1322//
1323// Recordings don't fit the generic `Client::sync` shape cleanly:
1324//
1325//   - the response carries per-item provenance (the platform-stamped
1326//     `r2Key`, plus whether bytes have already landed) that the
1327//     daemon needs in order to decide which rows still owe a PUT;
1328//   - the bytes upload is its own HTTP call (`PUT
1329//     /api/voice/recordings/{sourceId}/bytes`), not a JSON batch.
1330//
1331// Rather than overloading `SyncEndpoint` to carry these shapes, we
1332// expose two inherent methods on `Client` that compose the existing
1333// JSON / bytes-PUT primitives.
1334
1335impl Client {
1336    /// `POST /api/voice/recordings/sync` — idempotent batch upsert of
1337    /// recording metadata. Returns the per-item `r2Key` the daemon
1338    /// should target for the follow-up bytes PUT, and whether bytes
1339    /// have already landed for each row.
1340    ///
1341    /// Batch sizing rules match [`Client::sync`]: the platform rejects
1342    /// batches over 100 items; the daemon's uploader chunks at 50.
1343    pub async fn sync_recordings(
1344        &self,
1345        items: &[VoiceRecordingRecord],
1346    ) -> Result<VoiceRecordingsSyncResponse> {
1347        let stamped = stamp_schema_version::<VoiceRecordings>(items);
1348        let body = SyncRequest { items: stamped };
1349        self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
1350            .await
1351    }
1352
1353    /// `PUT /api/voice/recordings/{sourceId}/bytes` — upload the WAV
1354    /// bytes for a recording whose metadata was previously synced via
1355    /// [`Client::sync_recordings`]. The platform refuses (`HTTP 413`)
1356    /// if `bytes.len()` disagrees with the synced `sizeBytes`.
1357    ///
1358    /// `source_id` is path-segmented as-is; callers pass the
1359    /// daemon-side UUID they used for the metadata sync. Empty /
1360    /// path-traversal-shaped ids are not specifically guarded here —
1361    /// the platform's Zod schema rejects them server-side, so a
1362    /// malformed id surfaces as a 4xx via [`Error::Http`].
1363    pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
1364        if source_id.is_empty() {
1365            return Err(Error::BadRequest("source_id must not be empty".into()));
1366        }
1367        let path = format!("/api/voice/recordings/{source_id}/bytes");
1368        self.put_raw_bytes(&path, "audio/wav", bytes).await
1369    }
1370}
1371
1372// ---- Recording sharing ----------------------------------------------------
1373//
1374// Sharing is a *command* — mutate one recording's share state and get a
1375// result back — not the "batch upsert + cursor list" shape `SyncEndpoint`
1376// exists for (see wavekat-voice doc 38). So it's a typed method pair on
1377// `Client` (mirroring `whoami` rather than `sync::<E>()`), not a marker.
1378//
1379// The desktop daemon keeps only a *mirror* of what these return; the
1380// platform is authoritative for who may open a share. See
1381// `wavekat-voice/docs/38-share-a-recording.md`.
1382
1383/// Access tier for a shared recording, mirroring Loom's model. Wire-stable
1384/// snake_case strings — the platform's Zod schema validates against this
1385/// exact list, so a rename here would bounce every share command with a 400.
1386///
1387/// - `Private` — owner only (the default; "not shared").
1388/// - `Restricted` — owner + explicitly invited WaveKat accounts; the
1389///   recipient must be signed in as an invited identity ("protected by login").
1390/// - `Public` — anyone holding the capability link, no sign-in.
1391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1392#[serde(rename_all = "snake_case")]
1393pub enum ShareVisibility {
1394    Private,
1395    Restricted,
1396    Public,
1397}
1398
1399/// How a shared recording's caller/callee identity (the call's `party`) is
1400/// exposed to a viewer. Wire-stable snake_case, matching the platform's Zod
1401/// enum, so a rename here bounces a share command with a 400.
1402///
1403/// - `Full` — hidden behind a neutral direction label ("Inbound call").
1404/// - `Partial` — best-effort redaction (keeps shape, drops the value).
1405/// - `None` — the raw `party` is shown.
1406///
1407/// Absent on the wire → the platform defaults to `Partial` (identity
1408/// masked) — privacy-forward without fully erasing the caller. See
1409/// `wavekat-platform` docs/14.
1410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1411#[serde(rename_all = "snake_case")]
1412pub enum PartyMasking {
1413    Full,
1414    Partial,
1415    None,
1416}
1417
1418/// Body of `POST /api/voice/recordings/{id}/share` — create or update a
1419/// recording's share. The recording must already be synced (metadata +
1420/// bytes) or the platform returns 404.
1421#[derive(Debug, Clone, Serialize, Deserialize)]
1422#[serde(rename_all = "camelCase")]
1423pub struct ShareRecordingRequest {
1424    /// The artifact UUID, as synced (daemon-side `artifacts.id`). Goes in
1425    /// the URL path; carried in the struct so callers pass one value.
1426    pub recording_source_id: String,
1427    pub visibility: ShareVisibility,
1428    /// Restricted tier — the WaveKat-account emails allowed to open the
1429    /// share. Ignored (and omitted) for `Private` / `Public`.
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub invited_emails: Option<Vec<String>>,
1432    /// Per-share visibility controls (platform docs/14) — what a viewer may
1433    /// see. Each is omitted when unset; the platform then applies its
1434    /// privacy-forward default (identity masked, transcript hidden, audio
1435    /// shown, download off). NB the platform treats the request as the
1436    /// *full* desired state, so an omitted control is reset to its default,
1437    /// not preserved from a prior share — send all of them when editing an
1438    /// existing share's controls.
1439    #[serde(default, skip_serializing_if = "Option::is_none")]
1440    pub party_masking: Option<PartyMasking>,
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub show_transcript: Option<bool>,
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub show_audio: Option<bool>,
1445    /// Whether a viewer may *download* the WAV, distinct from hearing it.
1446    /// Off by default and only meaningful while `show_audio` is true — the
1447    /// platform forces it off otherwise (you can't save what you can't
1448    /// hear). A soft control: it hides the viewer's Download affordance,
1449    /// not the bytes a listener already fetches to play.
1450    #[serde(default, skip_serializing_if = "Option::is_none")]
1451    pub allow_download: Option<bool>,
1452    /// Per-channel playback defaults — which side is *audible by default*
1453    /// in the viewer's player (docs/14). A call has two channels: `local`
1454    /// (the owner's microphone, "your side") and `remote` (the other
1455    /// party, "their side"). `true` means that side starts muted; the
1456    /// viewer can still un-mute it, and the audio file is unchanged — this
1457    /// is only the player's starting state. Each is omitted when unset, in
1458    /// which case the platform defaults to audible (`false`). Only
1459    /// meaningful while `show_audio` is true; ignored when audio is hidden.
1460    #[serde(default, skip_serializing_if = "Option::is_none")]
1461    pub default_mute_local: Option<bool>,
1462    #[serde(default, skip_serializing_if = "Option::is_none")]
1463    pub default_mute_remote: Option<bool>,
1464    /// Phase 2 — out-of-band password gate. Omitted when unset.
1465    #[serde(default, skip_serializing_if = "Option::is_none")]
1466    pub password: Option<String>,
1467    /// Phase 2 — RFC 3339 auto-revoke time. Omitted when unset.
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub expires_at: Option<String>,
1470}
1471
1472/// The platform's response to a successful share command. `share_url` is
1473/// the full https link the user copies; `token` is the opaque capability
1474/// identifier embedded in it (returned separately so the daemon can store
1475/// it for display without re-parsing the URL).
1476#[derive(Debug, Clone, Serialize, Deserialize)]
1477#[serde(rename_all = "camelCase")]
1478pub struct ShareRecordingResponse {
1479    pub visibility: ShareVisibility,
1480    pub token: String,
1481    pub share_url: String,
1482    /// RFC 3339 — when the recording was first shared.
1483    pub shared_at: String,
1484    /// Effective visibility controls the platform stored (docs/14). Optional
1485    /// for tolerance — a platform predating the feature omits them, in which
1486    /// case the daemon should assume the defaults (identity masked, transcript
1487    /// hidden, audio shown, download off).
1488    #[serde(default, skip_serializing_if = "Option::is_none")]
1489    pub party_masking: Option<PartyMasking>,
1490    #[serde(default, skip_serializing_if = "Option::is_none")]
1491    pub show_transcript: Option<bool>,
1492    #[serde(default, skip_serializing_if = "Option::is_none")]
1493    pub show_audio: Option<bool>,
1494    /// Effective download permission — `show_audio && allow_download`, so
1495    /// it's never true when the audio is hidden. Absent on a platform
1496    /// predating the control (assume off).
1497    #[serde(default, skip_serializing_if = "Option::is_none")]
1498    pub allow_download: Option<bool>,
1499    /// Effective per-channel playback defaults the platform stored — which
1500    /// side starts muted in the viewer's player (docs/14). Absent on a
1501    /// platform predating the control (assume audible, `false`).
1502    #[serde(default, skip_serializing_if = "Option::is_none")]
1503    pub default_mute_local: Option<bool>,
1504    #[serde(default, skip_serializing_if = "Option::is_none")]
1505    pub default_mute_remote: Option<bool>,
1506}
1507
1508/// The platform's response to `GET /api/voice/recordings/{id}/share` — the
1509/// *authoritative* current share state for an owned recording. The POST
1510/// reply omits the invited-email list and a local mirror can't reflect a
1511/// share changed from another device, so the desktop "who can open this"
1512/// panel reads here.
1513///
1514/// A recording that was never shared (or whose share is revoked / expired)
1515/// comes back as [`ShareVisibility::Private`] with the optional fields
1516/// absent — the same "not shared" state DELETE leaves behind.
1517#[derive(Debug, Clone, Serialize, Deserialize)]
1518#[serde(rename_all = "camelCase")]
1519pub struct ShareStateResponse {
1520    pub visibility: ShareVisibility,
1521    /// Absent when `visibility == Private` (nothing is shared).
1522    #[serde(default, skip_serializing_if = "Option::is_none")]
1523    pub token: Option<String>,
1524    #[serde(default, skip_serializing_if = "Option::is_none")]
1525    pub share_url: Option<String>,
1526    /// RFC 3339 — when the recording was first shared. Absent when private.
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub shared_at: Option<String>,
1529    /// The restricted tier's audience (lowercased, de-duped). Present
1530    /// (possibly empty) only for [`ShareVisibility::Restricted`].
1531    #[serde(default, skip_serializing_if = "Option::is_none")]
1532    pub invited_emails: Option<Vec<String>>,
1533    /// Per-share visibility controls (docs/14). Present for a live share;
1534    /// absent when `Private` (nothing is shared, so no controls apply).
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub party_masking: Option<PartyMasking>,
1537    #[serde(default, skip_serializing_if = "Option::is_none")]
1538    pub show_transcript: Option<bool>,
1539    #[serde(default, skip_serializing_if = "Option::is_none")]
1540    pub show_audio: Option<bool>,
1541    /// Effective download permission — `show_audio && allow_download`, so
1542    /// never true when the audio is hidden. Absent when private.
1543    #[serde(default, skip_serializing_if = "Option::is_none")]
1544    pub allow_download: Option<bool>,
1545    /// Effective per-channel playback defaults — which side starts muted in
1546    /// the viewer's player (docs/14). Absent when private.
1547    #[serde(default, skip_serializing_if = "Option::is_none")]
1548    pub default_mute_local: Option<bool>,
1549    #[serde(default, skip_serializing_if = "Option::is_none")]
1550    pub default_mute_remote: Option<bool>,
1551}
1552
1553impl Client {
1554    /// `POST /api/voice/recordings/{id}/share` — create or update a share
1555    /// for an already-synced recording. Returns the capability link + token
1556    /// the desktop UI puts on the clipboard.
1557    ///
1558    /// Per the 404-not-403 ownership rule (doc 21 §"Authorization"), asking
1559    /// to share a recording the caller doesn't own surfaces as
1560    /// [`Error::Http`] with status 404 — existence doesn't leak.
1561    pub async fn share_recording(
1562        &self,
1563        req: &ShareRecordingRequest,
1564    ) -> Result<ShareRecordingResponse> {
1565        if req.recording_source_id.is_empty() {
1566            return Err(Error::BadRequest(
1567                "recording_source_id must not be empty".into(),
1568            ));
1569        }
1570        let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
1571        self.post_json::<ShareRecordingResponse, _>(&path, req)
1572            .await
1573    }
1574
1575    /// `GET /api/voice/recordings/{id}/share` — read the authoritative
1576    /// share state for an owned recording, including the restricted tier's
1577    /// invited emails (which the share command's reply omits). Like
1578    /// [`share_recording`](Self::share_recording), a recording the caller
1579    /// doesn't own surfaces as [`Error::Http`] with status 404.
1580    pub async fn get_recording_share(
1581        &self,
1582        recording_source_id: &str,
1583    ) -> Result<ShareStateResponse> {
1584        if recording_source_id.is_empty() {
1585            return Err(Error::BadRequest(
1586                "recording_source_id must not be empty".into(),
1587            ));
1588        }
1589        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1590        self.get_json::<ShareStateResponse>(&path).await
1591    }
1592
1593    /// `DELETE /api/voice/recordings/{id}/share` — revoke the share. The
1594    /// recording reverts to Private and any outstanding link returns 410.
1595    pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
1596        if recording_source_id.is_empty() {
1597            return Err(Error::BadRequest(
1598                "recording_source_id must not be empty".into(),
1599            ));
1600        }
1601        let path = format!("/api/voice/recordings/{recording_source_id}/share");
1602        self.delete(&path).await
1603    }
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609
1610    #[test]
1611    fn share_visibility_types_are_reachable_from_the_crate_root() {
1612        // Regression for the 0.0.13 gap: `PartyMasking` was added to this
1613        // module but left out of the crate-root `pub use voice::{…}`, and the
1614        // module is private — so a consumer (`wavekat-voice`) couldn't name
1615        // the type to build a `ShareRecordingRequest`. Pin every share-control
1616        // type to the root path so dropping one fails to compile here, not in
1617        // a downstream crate. The body never runs; reachability is the test.
1618        #[allow(dead_code)]
1619        fn _reachable() {
1620            let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
1621            let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
1622            let _: fn(&crate::ShareRecordingRequest) = |_| {};
1623            let _: fn(&crate::ShareRecordingResponse) = |_| {};
1624        }
1625    }
1626
1627    #[test]
1628    fn record_serializes_with_camel_case_keys() {
1629        let r = VoiceCallRecord {
1630            source_id: "11111111-1111-4111-8111-111111111111".into(),
1631            account_id: "22222222-2222-4222-8222-222222222222".into(),
1632            direction: VoiceCallDirection::Inbound,
1633            party: "+14155550123".into(),
1634            ring_at: "2026-05-16T10:00:00Z".into(),
1635            answer_at: Some("2026-05-16T10:00:05Z".into()),
1636            end_at: "2026-05-16T10:01:00Z".into(),
1637            duration_ms: Some(55_000),
1638            disposition: VoiceCallDisposition::Answered,
1639            end_reason: VoiceCallEndReason::HangupRemote,
1640            error: None,
1641            share_visibility: None,
1642            transfer_target: None,
1643            codec: None,
1644            flow_id: None,
1645            flow_name: None,
1646            flow_outcome: None,
1647            flow_steps: None,
1648            deleted_at: None,
1649            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1650        };
1651        let s = serde_json::to_string(&r).unwrap();
1652        assert!(s.contains("\"sourceId\":"), "{s}");
1653        assert!(s.contains("\"accountId\":"), "{s}");
1654        assert!(s.contains("\"ringAt\":"), "{s}");
1655        assert!(s.contains("\"endAt\":"), "{s}");
1656        assert!(s.contains("\"durationMs\":55000"), "{s}");
1657        // Optional `error` is None — should be omitted from the wire.
1658        assert!(!s.contains("\"error\""), "error should be omitted: {s}");
1659        // Optional `transferTarget` is None here — omitted from the wire,
1660        // exactly like a non-transferred call ships.
1661        assert!(
1662            !s.contains("\"transferTarget\""),
1663            "transferTarget should be omitted: {s}"
1664        );
1665        // Optional `codec` is None (never-answered call, or an older
1666        // daemon) — omitted from the wire, never `null`.
1667        assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
1668        // Envelope flattens to the top of the object — schemaVersion
1669        // sits next to the other fields rather than nested under
1670        // "envelope". Future resources rely on this layout.
1671        assert!(
1672            s.contains("\"schemaVersion\":1"),
1673            "schemaVersion should flatten: {s}"
1674        );
1675        // `extras` is None, so the envelope contributes no `extras`
1676        // key. Stays out of the row to keep the small/fast path.
1677        assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1678        // A live call omits the tombstone entirely rather than sending
1679        // `null` — every ordinary sync is a live call, so this is the
1680        // common path and it should stay off the wire.
1681        assert!(
1682            !s.contains("\"deletedAt\""),
1683            "deletedAt should be omitted on a live call: {s}"
1684        );
1685    }
1686
1687    #[test]
1688    fn call_tombstone_serializes_deleted_at() {
1689        // The delete-propagation mechanism: a deleted call rides up as
1690        // an ordinary upsert with `deletedAt` set (platform docs/22),
1691        // the same shape the account tombstone uses.
1692        let mut r = VoiceCallRecord {
1693            source_id: "11111111-1111-4111-8111-111111111111".into(),
1694            account_id: "22222222-2222-4222-8222-222222222222".into(),
1695            direction: VoiceCallDirection::Inbound,
1696            party: "+14155550123".into(),
1697            ring_at: "2026-05-16T10:00:00Z".into(),
1698            answer_at: None,
1699            end_at: "2026-05-16T10:01:00Z".into(),
1700            duration_ms: None,
1701            disposition: VoiceCallDisposition::Missed,
1702            end_reason: VoiceCallEndReason::HangupRemote,
1703            error: None,
1704            share_visibility: None,
1705            transfer_target: None,
1706            codec: None,
1707            flow_id: None,
1708            flow_name: None,
1709            flow_outcome: None,
1710            flow_steps: None,
1711            deleted_at: None,
1712            envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
1713        };
1714        r.deleted_at = Some("2026-07-30T12:00:00Z".into());
1715        let s = serde_json::to_string(&r).unwrap();
1716        assert!(s.contains("\"deletedAt\":\"2026-07-30T12:00:00Z\""), "{s}");
1717    }
1718
1719    #[test]
1720    fn call_record_parses_without_deleted_at() {
1721        // Reading back a live call from `GET /api/voice/calls`: the
1722        // platform sends `deletedAt: null`, and a platform build
1723        // predating the field sends nothing at all. Both must land as
1724        // `None` rather than failing the whole page.
1725        let raw = r#"{
1726            "sourceId": "a",
1727            "accountId": "b",
1728            "direction": "outbound",
1729            "party": "+14155550123",
1730            "ringAt": "2026-05-16T10:00:00Z",
1731            "endAt": "2026-05-16T10:01:00Z",
1732            "disposition": "answered",
1733            "endReason": "hangup_local"
1734        }"#;
1735        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1736        assert!(parsed.deleted_at.is_none());
1737
1738        let with_null: VoiceCallRecord =
1739            serde_json::from_str(&raw.replace('}', r#", "deletedAt": null }"#)).unwrap();
1740        assert!(with_null.deleted_at.is_none());
1741    }
1742
1743    #[test]
1744    fn calls_query_serializes_include_deleted() {
1745        // The delta-pull flag a device sets to learn about deletes made
1746        // elsewhere. Omitted when unset, so an ordinary list request is
1747        // unchanged.
1748        let live = VoiceCallsQuery::default();
1749        assert_eq!(serde_json::to_string(&live).unwrap(), "{}");
1750
1751        let delta = VoiceCallsQuery {
1752            include_deleted: Some(true),
1753            ..Default::default()
1754        };
1755        let s = serde_json::to_string(&delta).unwrap();
1756        assert!(s.contains("\"includeDeleted\":true"), "{s}");
1757    }
1758
1759    #[test]
1760    fn record_round_trips_optional_fields() {
1761        // An unanswered call has answer_at/duration_ms/error all absent.
1762        let raw = r#"{
1763            "sourceId": "a",
1764            "accountId": "b",
1765            "direction": "inbound",
1766            "party": "anonymous",
1767            "ringAt": "2026-05-16T10:00:00Z",
1768            "endAt": "2026-05-16T10:00:30Z",
1769            "disposition": "missed",
1770            "endReason": "missed"
1771        }"#;
1772        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1773        assert!(parsed.answer_at.is_none());
1774        assert!(parsed.duration_ms.is_none());
1775        assert!(parsed.error.is_none());
1776        assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
1777        assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
1778    }
1779
1780    #[test]
1781    fn query_omits_unset_fields() {
1782        let q = VoiceCallsQuery::default();
1783        let s = serde_json::to_string(&q).unwrap();
1784        // Empty object — every field skipped when None.
1785        assert_eq!(
1786            s, "{}",
1787            "default query should serialize to empty object: {s}"
1788        );
1789    }
1790
1791    #[test]
1792    fn enum_round_trip_via_json() {
1793        // The wire form for each direction/disposition/reason must
1794        // match what the daemon and platform expect — this guards
1795        // against accidental Rust-side renames.
1796        for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
1797            let s = serde_json::to_string(&d).unwrap();
1798            let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
1799            assert_eq!(d, back);
1800        }
1801        for d in [
1802            VoiceCallDisposition::Answered,
1803            VoiceCallDisposition::Missed,
1804            VoiceCallDisposition::Rejected,
1805            VoiceCallDisposition::Cancelled,
1806            VoiceCallDisposition::Failed,
1807        ] {
1808            let s = serde_json::to_string(&d).unwrap();
1809            let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
1810            assert_eq!(d, back);
1811        }
1812        for r in [
1813            VoiceCallEndReason::HangupLocal,
1814            VoiceCallEndReason::HangupRemote,
1815            VoiceCallEndReason::RejectedLocal,
1816            VoiceCallEndReason::RejectedRemote,
1817            VoiceCallEndReason::Missed,
1818            VoiceCallEndReason::CancelledLocal,
1819            VoiceCallEndReason::TransferredLocal,
1820            VoiceCallEndReason::ConnectionLost,
1821            VoiceCallEndReason::Failed,
1822        ] {
1823            let s = serde_json::to_string(&r).unwrap();
1824            let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1825            assert_eq!(r, back);
1826        }
1827    }
1828
1829    #[test]
1830    fn connection_lost_pins_its_wire_string() {
1831        // The platform's sync endpoint validates end reasons against
1832        // an exact string list — a rename here would make every
1833        // upload from a session-timer teardown bounce with a 400.
1834        let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1835        assert_eq!(s, "\"connection_lost\"");
1836    }
1837
1838    #[test]
1839    fn transferred_local_pins_its_wire_string() {
1840        // Same contract as `connection_lost`: the platform validates
1841        // against an exact string list, so a rename here would bounce
1842        // every transferred-call upload with a 400.
1843        let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1844        assert_eq!(s, "\"transferred_local\"");
1845    }
1846
1847    #[test]
1848    fn record_round_trips_transfer_target() {
1849        // A transferred call carries `transferTarget` both ways — the
1850        // daemon ships it (it's its own data, not read-only decoration),
1851        // and the platform echoes it back on read.
1852        let raw = r#"{
1853            "sourceId": "a",
1854            "accountId": "b",
1855            "direction": "inbound",
1856            "party": "Alice <sip:alice@example.com>",
1857            "ringAt": "2026-06-28T10:00:00Z",
1858            "answerAt": "2026-06-28T10:00:05Z",
1859            "endAt": "2026-06-28T10:00:30Z",
1860            "durationMs": 25000,
1861            "disposition": "answered",
1862            "endReason": "transferred_local",
1863            "transferTarget": "1002"
1864        }"#;
1865        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1866        assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1867        assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1868        // And it survives a re-serialize (daemon → platform direction).
1869        let s = serde_json::to_string(&parsed).unwrap();
1870        assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1871    }
1872
1873    #[test]
1874    fn codec_pins_its_wire_strings() {
1875        // The platform's sync endpoint validates the codec against an
1876        // exact string list, and the daemon's `CallCodec::as_str` emits
1877        // these same strings — a rename here would bounce every upload
1878        // from an answered call with a 400.
1879        for (codec, wire) in [
1880            (VoiceCallCodec::Opus, "\"opus\""),
1881            (VoiceCallCodec::Pcmu, "\"pcmu\""),
1882            (VoiceCallCodec::Pcma, "\"pcma\""),
1883        ] {
1884            assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1885            let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1886            assert_eq!(back, codec);
1887        }
1888    }
1889
1890    #[test]
1891    fn record_round_trips_codec() {
1892        // An answered call carries `codec` both ways — the daemon ships
1893        // it (its own data, like transferTarget), and the platform
1894        // echoes it back on read so the website can show the call's
1895        // audio quality.
1896        let raw = r#"{
1897            "sourceId": "a",
1898            "accountId": "b",
1899            "direction": "inbound",
1900            "party": "Alice <sip:alice@example.com>",
1901            "ringAt": "2026-07-03T10:00:00Z",
1902            "answerAt": "2026-07-03T10:00:05Z",
1903            "endAt": "2026-07-03T10:00:30Z",
1904            "durationMs": 25000,
1905            "disposition": "answered",
1906            "endReason": "hangup_remote",
1907            "codec": "opus"
1908        }"#;
1909        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1910        assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1911        // And it survives a re-serialize (daemon → platform direction).
1912        let s = serde_json::to_string(&parsed).unwrap();
1913        assert!(s.contains("\"codec\":\"opus\""), "{s}");
1914
1915        // A row from an older daemon has no codec — reads as None.
1916        let legacy = raw.replace(",\n            \"codec\": \"opus\"", "");
1917        let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1918        assert_eq!(parsed.codec, None);
1919    }
1920
1921    #[test]
1922    fn flow_outcome_pins_its_wire_strings() {
1923        // Three parties agree on these exact strings: the daemon's
1924        // `flow_outcome_to_str`, `wavekat_flow::trace::FlowOutcome`'s
1925        // snake_case serde, and the platform's zod enum. A rename here
1926        // 400s every flow-answered call's batch.
1927        for (outcome, wire) in [
1928            (VoiceCallFlowOutcome::Answered, "\"answered\""),
1929            (VoiceCallFlowOutcome::MessageLeft, "\"message_left\""),
1930            (VoiceCallFlowOutcome::Transferred, "\"transferred\""),
1931            (VoiceCallFlowOutcome::HungUp, "\"hung_up\""),
1932            (VoiceCallFlowOutcome::Aborted, "\"aborted\""),
1933            (VoiceCallFlowOutcome::Defect, "\"defect\""),
1934        ] {
1935            assert_eq!(serde_json::to_string(&outcome).unwrap(), wire);
1936            let back: VoiceCallFlowOutcome = serde_json::from_str(wire).unwrap();
1937            assert_eq!(back, outcome);
1938        }
1939    }
1940
1941    #[test]
1942    fn record_round_trips_flow_attribution() {
1943        // A flow-answered call carries which flow took it and how the
1944        // run ended, both ways: the daemon ships them, the platform
1945        // echoes them so the website can say "Answered by “X”" and show
1946        // the run's own outcome instead of the misleading SIP one.
1947        let raw = r#"{
1948            "sourceId": "a",
1949            "accountId": "b",
1950            "direction": "inbound",
1951            "party": "Alice <sip:alice@example.com>",
1952            "ringAt": "2026-07-03T10:00:00Z",
1953            "answerAt": "2026-07-03T10:00:05Z",
1954            "endAt": "2026-07-03T10:00:30Z",
1955            "durationMs": 25000,
1956            "disposition": "answered",
1957            "endReason": "hangup_local",
1958            "flowId": "flow_after_hours",
1959            "flowName": "After hours",
1960            "flowOutcome": "message_left"
1961        }"#;
1962        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1963        assert_eq!(parsed.flow_id.as_deref(), Some("flow_after_hours"));
1964        assert_eq!(parsed.flow_name.as_deref(), Some("After hours"));
1965        assert_eq!(parsed.flow_outcome, Some(VoiceCallFlowOutcome::MessageLeft));
1966
1967        let s = serde_json::to_string(&parsed).unwrap();
1968        assert!(s.contains("\"flowId\":\"flow_after_hours\""), "{s}");
1969        assert!(s.contains("\"flowName\":\"After hours\""), "{s}");
1970        assert!(s.contains("\"flowOutcome\":\"message_left\""), "{s}");
1971    }
1972
1973    #[test]
1974    fn record_round_trips_a_flow_step_trace() {
1975        // Pins the per-step field names. These are consumed by the
1976        // platform's Zod schema on one side and produced by the daemon's
1977        // projection on the other; a silent rename here breaks both.
1978        let raw = r#"{
1979            "sourceId": "a",
1980            "accountId": "b",
1981            "direction": "inbound",
1982            "party": "sip:alice@example.com",
1983            "ringAt": "2026-07-03T10:00:00Z",
1984            "answerAt": "2026-07-03T10:00:05Z",
1985            "endAt": "2026-07-03T10:00:30Z",
1986            "disposition": "answered",
1987            "endReason": "hangup_local",
1988            "flowId": "f",
1989            "flowName": "F",
1990            "flowSteps": [
1991                { "atMs": 0, "kind": "spoke", "node": "greeting" },
1992                { "atMs": 4200, "kind": "menu_choice", "digit": "2" },
1993                { "atMs": 9100, "kind": "message_recorded", "secs": 31 }
1994            ]
1995        }"#;
1996        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1997        let steps = parsed.flow_steps.as_deref().expect("steps present");
1998        assert_eq!(steps.len(), 3);
1999        assert_eq!(steps[1].kind, "menu_choice");
2000        assert_eq!(steps[1].digit.as_deref(), Some("2"));
2001        assert_eq!(steps[2].secs, Some(31));
2002        // Absent per-step fields stay absent rather than serializing as
2003        // nulls — same contract as the record's own optional fields.
2004        let s = serde_json::to_string(&steps[0]).unwrap();
2005        assert_eq!(s, r#"{"atMs":0,"kind":"spoke","node":"greeting"}"#);
2006    }
2007
2008    #[test]
2009    fn flow_step_accepts_a_kind_this_build_does_not_know() {
2010        // The whole reason `kind` is a String. A consumer pinned to an
2011        // older crate version must still deserialize a newer daemon's
2012        // trace — rejecting would fail the entire call record, not one
2013        // step.
2014        let step: VoiceCallFlowStep =
2015            serde_json::from_str(r#"{"atMs": 10, "kind": "consulted_the_oracle"}"#).unwrap();
2016        assert_eq!(step.kind, "consulted_the_oracle");
2017        assert_eq!(step.digit, None);
2018    }
2019
2020    #[test]
2021    fn record_omits_flow_steps_for_a_human_answered_call() {
2022        // A call the user took themselves has no trace. The field must
2023        // stay off the wire entirely rather than serializing as null.
2024        let raw = r#"{
2025            "sourceId": "a",
2026            "accountId": "b",
2027            "direction": "inbound",
2028            "party": "sip:alice@example.com",
2029            "ringAt": "2026-07-03T10:00:00Z",
2030            "endAt": "2026-07-03T10:00:30Z",
2031            "disposition": "answered",
2032            "endReason": "hangup_local"
2033        }"#;
2034        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2035        assert!(parsed.flow_steps.is_none());
2036        let s = serde_json::to_string(&parsed).unwrap();
2037        assert!(!s.contains("flowSteps"), "{s}");
2038    }
2039
2040    #[test]
2041    fn record_omits_flow_fields_for_a_human_answered_call() {
2042        // Calls the user took themselves — and every row from a daemon
2043        // predating call flows — carry none of the three. They must
2044        // stay off the wire entirely, not serialize as nulls.
2045        let raw = r#"{
2046            "sourceId": "a",
2047            "accountId": "b",
2048            "direction": "inbound",
2049            "party": "sip:alice@example.com",
2050            "ringAt": "2026-07-03T10:00:00Z",
2051            "endAt": "2026-07-03T10:00:30Z",
2052            "disposition": "answered",
2053            "endReason": "hangup_remote"
2054        }"#;
2055        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2056        assert_eq!(parsed.flow_id, None);
2057        assert_eq!(parsed.flow_name, None);
2058        assert_eq!(parsed.flow_outcome, None);
2059
2060        let s = serde_json::to_string(&parsed).unwrap();
2061        assert!(!s.contains("\"flowId\""), "flowId should be omitted: {s}");
2062        assert!(
2063            !s.contains("\"flowName\""),
2064            "flowName should be omitted: {s}"
2065        );
2066        assert!(
2067            !s.contains("\"flowOutcome\""),
2068            "flowOutcome should be omitted: {s}"
2069        );
2070    }
2071
2072    #[test]
2073    fn voice_calls_marker_resource_is_calls() {
2074        assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
2075    }
2076
2077    #[test]
2078    fn record_accepts_unknown_extras_for_forward_compat() {
2079        // A newer client shipping a `notes` field that this platform
2080        // version doesn't have a column for should round-trip via
2081        // the `extras` envelope. The platform persists the blob
2082        // verbatim; a future deploy can promote it to a typed
2083        // column without data loss.
2084        let raw = r#"{
2085            "sourceId": "a",
2086            "accountId": "b",
2087            "direction": "inbound",
2088            "party": "anon",
2089            "ringAt": "2026-05-16T10:00:00Z",
2090            "endAt": "2026-05-16T10:00:30Z",
2091            "disposition": "answered",
2092            "endReason": "hangup_remote",
2093            "schemaVersion": 2,
2094            "extras": { "notes": "from staging build" }
2095        }"#;
2096        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2097        assert_eq!(parsed.envelope.schema_version, Some(2));
2098        let extras = parsed.envelope.extras.as_ref().expect("extras present");
2099        assert_eq!(extras["notes"], "from staging build");
2100    }
2101
2102    #[test]
2103    fn call_record_parses_share_visibility_from_list_response() {
2104        // The list / detail endpoints decorate a call with the tier of any
2105        // active share on its recording, so a consumer can badge the row.
2106        let raw = r#"{
2107            "sourceId": "a",
2108            "accountId": "b",
2109            "direction": "outbound",
2110            "party": "+14155550123",
2111            "ringAt": "2026-05-16T10:00:00Z",
2112            "endAt": "2026-05-16T10:00:30Z",
2113            "disposition": "answered",
2114            "endReason": "hangup_remote",
2115            "shareVisibility": "public"
2116        }"#;
2117        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2118        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
2119
2120        let restricted = raw.replace("public", "restricted");
2121        let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
2122        assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
2123    }
2124
2125    #[test]
2126    fn call_record_unshared_has_no_share_visibility() {
2127        // Absent (older platform, or an unshared call) and an explicit
2128        // `null` both read as "not shared" — never `Some(Private)`.
2129        let base = r#"{
2130            "sourceId": "a",
2131            "accountId": "b",
2132            "direction": "inbound",
2133            "party": "anon",
2134            "ringAt": "2026-05-16T10:00:00Z",
2135            "endAt": "2026-05-16T10:00:30Z",
2136            "disposition": "missed",
2137            "endReason": "missed"
2138        }"#;
2139        let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
2140        assert_eq!(parsed.share_visibility, None);
2141
2142        let with_null = base.replace(
2143            r#""endReason": "missed""#,
2144            r#""endReason": "missed", "shareVisibility": null"#,
2145        );
2146        let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
2147        assert_eq!(parsed.share_visibility, None);
2148    }
2149
2150    #[test]
2151    fn synced_call_omits_share_visibility() {
2152        // `share_visibility` is read-only decoration: a call uploaded via
2153        // sync must not carry it on the wire (skip_serializing_if = None),
2154        // so the round trip from a sync-shaped record stays clean.
2155        let raw = r#"{
2156            "sourceId": "a",
2157            "accountId": "b",
2158            "direction": "inbound",
2159            "party": "anon",
2160            "ringAt": "2026-05-16T10:00:00Z",
2161            "endAt": "2026-05-16T10:00:30Z",
2162            "disposition": "answered",
2163            "endReason": "hangup_remote"
2164        }"#;
2165        let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
2166        assert_eq!(parsed.share_visibility, None);
2167        let s = serde_json::to_string(&parsed).unwrap();
2168        assert!(
2169            !s.contains("shareVisibility"),
2170            "sync payload leaked share_visibility: {s}"
2171        );
2172    }
2173
2174    #[test]
2175    fn recording_marker_resource_is_recordings() {
2176        // Path constant drives the URL in `Client::sync_recordings`;
2177        // a rename here would silently 404 against the platform.
2178        assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
2179    }
2180
2181    #[test]
2182    fn recording_record_serializes_with_camel_case_and_envelope() {
2183        let r = VoiceRecordingRecord {
2184            source_id: "11111111-1111-4111-8111-111111111111".into(),
2185            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
2186            size_bytes: 44 + 64_000,
2187            duration_ms: 2_000,
2188            sample_rate: 8_000,
2189            channels: 2,
2190            created_at: "2026-05-16T10:01:05Z".into(),
2191            envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
2192        };
2193        let s = serde_json::to_string(&r).unwrap();
2194        // Field-by-field wire contract — these strings are also what
2195        // the platform's Zod schema expects.
2196        assert!(s.contains("\"sourceId\":"), "{s}");
2197        assert!(s.contains("\"callSourceId\":"), "{s}");
2198        assert!(s.contains("\"sizeBytes\":64044"), "{s}");
2199        assert!(s.contains("\"durationMs\":2000"), "{s}");
2200        assert!(s.contains("\"sampleRate\":8000"), "{s}");
2201        assert!(s.contains("\"channels\":2"), "{s}");
2202        assert!(s.contains("\"createdAt\":"), "{s}");
2203        // Envelope flattens to the top of the object, same as VoiceCallRecord.
2204        assert!(s.contains("\"schemaVersion\":1"), "{s}");
2205    }
2206
2207    #[test]
2208    fn recordings_sync_response_round_trips() {
2209        // The richer-than-generic response carries per-item provenance —
2210        // the daemon's uploader reads `r2Key` for the bytes follow-up
2211        // and `bytesUploaded` to short-circuit when the row already
2212        // landed on a previous cycle.
2213        let raw = r#"{
2214            "accepted": 2,
2215            "skipped": 0,
2216            "items": [
2217                {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
2218                {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
2219            ]
2220        }"#;
2221        let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
2222        assert_eq!(parsed.accepted, 2);
2223        assert_eq!(parsed.items.len(), 2);
2224        assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
2225        assert!(!parsed.items[0].bytes_uploaded);
2226        assert!(parsed.items[1].bytes_uploaded);
2227    }
2228
2229    #[test]
2230    fn install_heartbeat_request_serializes_with_camel_case_keys() {
2231        let req = InstallHeartbeatRequest {
2232            install_id: "11111111-1111-4111-8111-111111111111".into(),
2233            app_version: "0.0.21".into(),
2234            os: "macos".into(),
2235            os_version: Some("15.5.0".into()),
2236            arch: Some("aarch64".into()),
2237            locale: Some("en-NZ".into()),
2238            distribution: Some("mas".into()),
2239            fleet: InstallHeartbeatFleet::default(),
2240        };
2241        let s = serde_json::to_string(&req).unwrap();
2242        assert!(s.contains("\"installId\":"), "{s}");
2243        assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
2244        assert!(s.contains("\"os\":\"macos\""), "{s}");
2245        assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
2246        assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
2247        assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
2248        assert!(s.contains("\"distribution\":\"mas\""), "{s}");
2249    }
2250
2251    #[test]
2252    fn install_heartbeat_request_omits_absent_optional_fields() {
2253        // A host where the OS version / locale probe came up empty
2254        // shouldn't send `null` — keeping the keys out lets the
2255        // platform's Zod `.optional()` accept the body and the column
2256        // stay NULL rather than the string "null".
2257        let req = InstallHeartbeatRequest {
2258            install_id: "x".into(),
2259            app_version: "0.0.21".into(),
2260            os: "linux".into(),
2261            os_version: None,
2262            arch: None,
2263            locale: None,
2264            distribution: None,
2265            fleet: InstallHeartbeatFleet::default(),
2266        };
2267        let s = serde_json::to_string(&req).unwrap();
2268        assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
2269        assert!(!s.contains("arch"), "arch should be omitted: {s}");
2270        assert!(!s.contains("locale"), "locale should be omitted: {s}");
2271        assert!(
2272            !s.contains("distribution"),
2273            "distribution should be omitted: {s}"
2274        );
2275    }
2276
2277    #[test]
2278    fn install_heartbeat_request_with_default_fleet_matches_pre_fleet_key_set() {
2279        // `fleet: InstallHeartbeatFleet::default()` (all `None`) must
2280        // serialize to exactly the key set the platform saw before this
2281        // struct existed — flatten + `skip_serializing_if` must not
2282        // leak an empty-object marker or any of the ten new keys.
2283        let req = InstallHeartbeatRequest {
2284            install_id: "11111111-1111-4111-8111-111111111111".into(),
2285            app_version: "0.0.21".into(),
2286            os: "macos".into(),
2287            os_version: Some("15.5.0".into()),
2288            arch: Some("aarch64".into()),
2289            locale: Some("en-NZ".into()),
2290            distribution: Some("mas".into()),
2291            fleet: InstallHeartbeatFleet::default(),
2292        };
2293        let value: serde_json::Value = serde_json::to_value(&req).unwrap();
2294        let mut keys: Vec<&str> = value
2295            .as_object()
2296            .unwrap()
2297            .keys()
2298            .map(String::as_str)
2299            .collect();
2300        keys.sort_unstable();
2301        let mut expected = vec![
2302            "installId",
2303            "appVersion",
2304            "os",
2305            "osVersion",
2306            "arch",
2307            "locale",
2308            "distribution",
2309        ];
2310        expected.sort_unstable();
2311        assert_eq!(keys, expected, "unexpected key set: {value}");
2312    }
2313
2314    #[test]
2315    fn install_heartbeat_request_with_full_fleet_serializes_camel_case() {
2316        let req = InstallHeartbeatRequest {
2317            install_id: "11111111-1111-4111-8111-111111111111".into(),
2318            app_version: "0.0.21".into(),
2319            os: "macos".into(),
2320            os_version: Some("15.5.0".into()),
2321            arch: Some("aarch64".into()),
2322            locale: Some("en-NZ".into()),
2323            distribution: Some("mas".into()),
2324            fleet: InstallHeartbeatFleet {
2325                build_sha: Some("deadbeef".into()),
2326                install_source: Some("mas".into()),
2327                update_channel: Some("stable".into()),
2328                updater_enabled: Some(false),
2329                updater_status: Some("idle".into()),
2330                updater_version: Some("0.0.22".into()),
2331                updater_checked_at: Some("2026-09-07T10:00:00.000Z".into()),
2332                updater_error: Some("network timeout".into()),
2333                native_arch: Some("arm64".into()),
2334                started_at: Some("2026-09-07T09:00:00.000Z".into()),
2335            },
2336        };
2337        let value: serde_json::Value = serde_json::to_value(&req).unwrap();
2338        assert_eq!(value["buildSha"], "deadbeef");
2339        assert_eq!(value["installSource"], "mas");
2340        assert_eq!(value["updateChannel"], "stable");
2341        assert_eq!(value["updaterEnabled"], serde_json::json!(false));
2342        assert!(value["updaterEnabled"].is_boolean(), "{value}");
2343        assert_eq!(value["updaterStatus"], "idle");
2344        assert_eq!(value["updaterVersion"], "0.0.22");
2345        assert_eq!(value["updaterCheckedAt"], "2026-09-07T10:00:00.000Z");
2346        assert_eq!(value["updaterError"], "network timeout");
2347        assert_eq!(value["nativeArch"], "arm64");
2348        assert_eq!(value["startedAt"], "2026-09-07T09:00:00.000Z");
2349    }
2350
2351    #[test]
2352    fn install_heartbeat_request_full_fleet_round_trips() {
2353        let req = InstallHeartbeatRequest {
2354            install_id: "11111111-1111-4111-8111-111111111111".into(),
2355            app_version: "0.0.21".into(),
2356            os: "macos".into(),
2357            os_version: Some("15.5.0".into()),
2358            arch: Some("aarch64".into()),
2359            locale: Some("en-NZ".into()),
2360            distribution: Some("mas".into()),
2361            fleet: InstallHeartbeatFleet {
2362                build_sha: Some("deadbeef".into()),
2363                install_source: Some("mas".into()),
2364                update_channel: Some("stable".into()),
2365                updater_enabled: Some(false),
2366                updater_status: Some("idle".into()),
2367                updater_version: Some("0.0.22".into()),
2368                updater_checked_at: Some("2026-09-07T10:00:00.000Z".into()),
2369                updater_error: Some("network timeout".into()),
2370                native_arch: Some("arm64".into()),
2371                started_at: Some("2026-09-07T09:00:00.000Z".into()),
2372            },
2373        };
2374        let s = serde_json::to_string(&req).unwrap();
2375        let round_tripped: InstallHeartbeatRequest = serde_json::from_str(&s).unwrap();
2376        assert_eq!(round_tripped, req);
2377    }
2378
2379    #[test]
2380    fn install_heartbeat_request_without_fleet_keys_deserializes_to_default_fleet() {
2381        // A body from a daemon that predates the fleet fields (or one
2382        // that simply has nothing to report) carries none of the ten
2383        // new keys. It must still parse, with `fleet` coming back as
2384        // the all-`None` default.
2385        let raw = r#"{
2386            "installId": "11111111-1111-4111-8111-111111111111",
2387            "appVersion": "0.0.21",
2388            "os": "macos",
2389            "osVersion": "15.5.0",
2390            "arch": "aarch64",
2391            "locale": "en-NZ",
2392            "distribution": "mas"
2393        }"#;
2394        let parsed: InstallHeartbeatRequest = serde_json::from_str(raw).unwrap();
2395        assert_eq!(parsed.fleet, InstallHeartbeatFleet::default());
2396    }
2397
2398    #[test]
2399    fn install_heartbeat_response_parses_platform_shape() {
2400        let raw = r#"{
2401            "id": "abc-123",
2402            "installId": "11111111-1111-4111-8111-111111111111",
2403            "appVersion": "0.0.21",
2404            "os": "macos",
2405            "osVersion": "15.5.0",
2406            "arch": "aarch64",
2407            "locale": null,
2408            "firstSeenAt": "2026-05-31T10:00:00.000Z",
2409            "lastSeenAt": "2026-05-31T10:00:00.000Z"
2410        }"#;
2411        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2412        assert_eq!(parsed.id, "abc-123");
2413        assert_eq!(parsed.app_version, "0.0.21");
2414        assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
2415        assert!(parsed.locale.is_none());
2416        // The fixture above carries no `distribution` key at all, which
2417        // is what a platform deployed before the field looks like. It
2418        // must parse, not error — hence `#[serde(default)]`.
2419        assert!(parsed.distribution.is_none());
2420    }
2421
2422    #[test]
2423    fn install_heartbeat_response_reads_the_distribution_back() {
2424        let raw = r#"{
2425            "id": "abc-123",
2426            "installId": "11111111-1111-4111-8111-111111111111",
2427            "appVersion": "0.0.48",
2428            "os": "macos",
2429            "osVersion": "15.5.0",
2430            "arch": "aarch64",
2431            "locale": "en-NZ",
2432            "distribution": "mas",
2433            "firstSeenAt": "2026-08-22T10:00:00.000Z",
2434            "lastSeenAt": "2026-08-22T10:00:00.000Z"
2435        }"#;
2436        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2437        assert_eq!(parsed.distribution.as_deref(), Some("mas"));
2438    }
2439
2440    #[test]
2441    fn install_heartbeat_response_accepts_an_unknown_distribution() {
2442        // Free text by contract: the platform stores whatever arrives so
2443        // a new distribution can ship without a server release. Parsing
2444        // it into an enum here would undo that on the client side.
2445        let raw = r#"{
2446            "id": "abc-123",
2447            "installId": "11111111-1111-4111-8111-111111111111",
2448            "appVersion": "0.1.0",
2449            "os": "windows",
2450            "osVersion": null,
2451            "arch": "x86_64",
2452            "locale": null,
2453            "distribution": "msstore",
2454            "firstSeenAt": "2026-08-22T10:00:00.000Z",
2455            "lastSeenAt": "2026-08-22T10:00:00.000Z"
2456        }"#;
2457        let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
2458        assert_eq!(parsed.distribution.as_deref(), Some("msstore"));
2459    }
2460
2461    #[test]
2462    fn system_info_detect_fills_os_and_arch() {
2463        // os / arch come from compile-time consts, so they're always
2464        // non-empty on every supported target. os_version / locale are
2465        // best-effort and intentionally not asserted.
2466        let sys = SystemInfo::detect();
2467        assert!(!sys.os.is_empty(), "os should be a non-empty target string");
2468        assert!(
2469            !sys.arch.is_empty(),
2470            "arch should be a non-empty target string"
2471        );
2472    }
2473
2474    #[test]
2475    fn transcripts_marker_resource_is_transcripts() {
2476        assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
2477    }
2478
2479    #[test]
2480    fn transcript_record_serializes_with_camel_case_and_channel_enum() {
2481        let r = VoiceTranscriptRecord {
2482            source_id: "1".into(),
2483            call_source_id: "22222222-2222-4222-8222-222222222222".into(),
2484            channel: VoiceTranscriptChannel::Remote,
2485            ts_ms: 100,
2486            end_ms: 1_500,
2487            text: "hello".into(),
2488            envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
2489        };
2490        let s = serde_json::to_string(&r).unwrap();
2491        assert!(s.contains("\"sourceId\":"), "{s}");
2492        assert!(s.contains("\"callSourceId\":"), "{s}");
2493        // The channel enum is wire-stable snake_case — matches the
2494        // platform's Zod `enum(VOICE_TRANSCRIPT_CHANNELS)`.
2495        assert!(s.contains("\"channel\":\"remote\""), "{s}");
2496        assert!(s.contains("\"tsMs\":100"), "{s}");
2497        assert!(s.contains("\"endMs\":1500"), "{s}");
2498        assert!(s.contains("\"text\":\"hello\""), "{s}");
2499        assert!(s.contains("\"schemaVersion\":1"), "{s}");
2500    }
2501
2502    #[test]
2503    fn share_visibility_pins_its_wire_strings() {
2504        // The platform validates these against an exact string list; a
2505        // rename would bounce every share command with a 400.
2506        assert_eq!(
2507            serde_json::to_string(&ShareVisibility::Private).unwrap(),
2508            "\"private\""
2509        );
2510        assert_eq!(
2511            serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
2512            "\"restricted\""
2513        );
2514        assert_eq!(
2515            serde_json::to_string(&ShareVisibility::Public).unwrap(),
2516            "\"public\""
2517        );
2518        for v in [
2519            ShareVisibility::Private,
2520            ShareVisibility::Restricted,
2521            ShareVisibility::Public,
2522        ] {
2523            let s = serde_json::to_string(&v).unwrap();
2524            let back: ShareVisibility = serde_json::from_str(&s).unwrap();
2525            assert_eq!(v, back);
2526        }
2527    }
2528
2529    #[test]
2530    fn share_request_serializes_with_camel_case_and_omits_unset() {
2531        let req = ShareRecordingRequest {
2532            recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
2533            visibility: ShareVisibility::Public,
2534            invited_emails: None,
2535            party_masking: None,
2536            show_transcript: None,
2537            show_audio: None,
2538            allow_download: None,
2539            default_mute_local: None,
2540            default_mute_remote: None,
2541            password: None,
2542            expires_at: None,
2543        };
2544        let s = serde_json::to_string(&req).unwrap();
2545        assert!(s.contains("\"recordingSourceId\":"), "{s}");
2546        assert!(s.contains("\"visibility\":\"public\""), "{s}");
2547        // Phase-2 / tier-specific / visibility-control fields stay off the
2548        // wire when unset so the platform's `.optional()` schema accepts the
2549        // body (and the omitted controls fall to the platform defaults).
2550        assert!(!s.contains("invitedEmails"), "{s}");
2551        assert!(!s.contains("partyMasking"), "{s}");
2552        assert!(!s.contains("showTranscript"), "{s}");
2553        assert!(!s.contains("showAudio"), "{s}");
2554        assert!(!s.contains("allowDownload"), "{s}");
2555        assert!(!s.contains("defaultMuteLocal"), "{s}");
2556        assert!(!s.contains("defaultMuteRemote"), "{s}");
2557        assert!(!s.contains("password"), "{s}");
2558        assert!(!s.contains("expiresAt"), "{s}");
2559    }
2560
2561    #[test]
2562    fn share_request_serializes_visibility_controls_camel_case() {
2563        let req = ShareRecordingRequest {
2564            recording_source_id: "a".into(),
2565            visibility: ShareVisibility::Public,
2566            invited_emails: None,
2567            party_masking: Some(PartyMasking::Partial),
2568            show_transcript: Some(false),
2569            show_audio: Some(true),
2570            allow_download: Some(true),
2571            default_mute_local: Some(false),
2572            default_mute_remote: Some(true),
2573            password: None,
2574            expires_at: None,
2575        };
2576        let s = serde_json::to_string(&req).unwrap();
2577        assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
2578        assert!(s.contains("\"showTranscript\":false"), "{s}");
2579        assert!(s.contains("\"showAudio\":true"), "{s}");
2580        assert!(s.contains("\"allowDownload\":true"), "{s}");
2581        // The owner muted their own side by default but left the other
2582        // party audible — both ride the wire as camelCase booleans.
2583        assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
2584        assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
2585    }
2586
2587    #[test]
2588    fn share_request_carries_invited_emails_for_restricted() {
2589        let req = ShareRecordingRequest {
2590            recording_source_id: "a".into(),
2591            visibility: ShareVisibility::Restricted,
2592            invited_emails: Some(vec!["alex@example.com".into()]),
2593            party_masking: None,
2594            show_transcript: None,
2595            show_audio: None,
2596            allow_download: None,
2597            default_mute_local: None,
2598            default_mute_remote: None,
2599            password: None,
2600            expires_at: None,
2601        };
2602        let s = serde_json::to_string(&req).unwrap();
2603        assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
2604        assert!(
2605            s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
2606            "{s}"
2607        );
2608    }
2609
2610    #[test]
2611    fn share_response_parses_platform_shape() {
2612        let raw = r#"{
2613            "visibility": "public",
2614            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
2615            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
2616            "sharedAt": "2026-06-19T10:00:00.000Z"
2617        }"#;
2618        let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
2619        assert_eq!(parsed.visibility, ShareVisibility::Public);
2620        assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
2621        assert!(parsed.share_url.ends_with(&parsed.token));
2622    }
2623
2624    #[test]
2625    fn share_state_parses_restricted_with_invited_emails() {
2626        // The GET read carries the audience back — this is the field the
2627        // POST reply omits and the desktop "who can open this" panel needs.
2628        let raw = r#"{
2629            "visibility": "restricted",
2630            "token": "Zr7-x9F2k1QpLmN4sT8wYa",
2631            "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
2632            "sharedAt": "2026-06-19T10:00:00.000Z",
2633            "invitedEmails": ["bob@example.com", "carol@example.com"],
2634            "partyMasking": "full",
2635            "showTranscript": true,
2636            "showAudio": false,
2637            "allowDownload": false,
2638            "defaultMuteLocal": false,
2639            "defaultMuteRemote": true
2640        }"#;
2641        let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
2642        assert_eq!(parsed.visibility, ShareVisibility::Restricted);
2643        assert_eq!(
2644            parsed.invited_emails.as_deref(),
2645            Some(
2646                [
2647                    "bob@example.com".to_string(),
2648                    "carol@example.com".to_string()
2649                ]
2650                .as_slice()
2651            )
2652        );
2653        // The visibility controls ride back on the live-share read.
2654        assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
2655        assert_eq!(parsed.show_transcript, Some(true));
2656        assert_eq!(parsed.show_audio, Some(false));
2657        // Audio hidden here, so download comes back off (platform folds the two).
2658        assert_eq!(parsed.allow_download, Some(false));
2659        // Per-channel playback defaults ride back too.
2660        assert_eq!(parsed.default_mute_local, Some(false));
2661        assert_eq!(parsed.default_mute_remote, Some(true));
2662    }
2663
2664    #[test]
2665    fn share_state_parses_private_with_fields_absent() {
2666        // A never-shared (or revoked) recording reports private with no
2667        // token / url / emails — the optional fields stay None.
2668        let parsed: ShareStateResponse =
2669            serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
2670        assert_eq!(parsed.visibility, ShareVisibility::Private);
2671        assert!(parsed.token.is_none());
2672        assert!(parsed.share_url.is_none());
2673        assert!(parsed.shared_at.is_none());
2674        assert!(parsed.invited_emails.is_none());
2675    }
2676
2677    #[test]
2678    fn share_request_rejects_empty_source_id_before_hitting_network() {
2679        // Guarded client-side so an empty id can't produce a path like
2680        // `/api/voice/recordings//share` that 404s confusingly.
2681        let req = ShareRecordingRequest {
2682            recording_source_id: String::new(),
2683            visibility: ShareVisibility::Private,
2684            invited_emails: None,
2685            party_masking: None,
2686            show_transcript: None,
2687            show_audio: None,
2688            allow_download: None,
2689            default_mute_local: None,
2690            default_mute_remote: None,
2691            password: None,
2692            expires_at: None,
2693        };
2694        // We can't call the async method without a runtime here, but the
2695        // guard mirrors `upload_recording_bytes` — assert the precondition
2696        // shape the method checks.
2697        assert!(req.recording_source_id.is_empty());
2698    }
2699
2700    // ---- VoiceAccounts ----
2701
2702    fn sample_account() -> VoiceAccountRecord {
2703        VoiceAccountRecord {
2704            source_id: "11111111-1111-4111-8111-111111111111".into(),
2705            enabled: true,
2706            display_name: "Work line".into(),
2707            username: "alice".into(),
2708            domain: "sip.example.com".into(),
2709            auth_username: Some("alice-auth".into()),
2710            server: Some("sip.example.com".into()),
2711            port: Some(5060),
2712            transport: VoiceTransport::Udp,
2713            register_expires: 60,
2714            keepalive_secs: Some(50),
2715            disclosure_enabled: true,
2716            updated_at: "2026-06-20T10:00:00Z".into(),
2717            deleted_at: None,
2718            envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
2719        }
2720    }
2721
2722    #[test]
2723    fn accounts_marker_resource_is_accounts() {
2724        // Path constant drives the URL in `Client::sync` / `Client::list`;
2725        // a rename here would silently 404 against the platform.
2726        assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
2727    }
2728
2729    #[test]
2730    fn account_record_serializes_with_camel_case_and_envelope() {
2731        let s = serde_json::to_string(&sample_account()).unwrap();
2732        // Field-by-field wire contract — also what the platform's Zod
2733        // schema expects.
2734        assert!(s.contains("\"sourceId\":"), "{s}");
2735        assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
2736        assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
2737        assert!(s.contains("\"registerExpires\":60"), "{s}");
2738        assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
2739        assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
2740        assert!(s.contains("\"transport\":\"udp\""), "{s}");
2741        assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
2742        // A live line carries no tombstone.
2743        assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
2744        // The secret never crosses this wire, by construction.
2745        assert!(!s.contains("password"), "no password field: {s}");
2746        // Envelope flattens to the top, same as the other resources.
2747        assert!(s.contains("\"schemaVersion\":1"), "{s}");
2748    }
2749
2750    #[test]
2751    fn account_tombstone_serializes_deleted_at() {
2752        // A soft-delete rides as an upsert with deletedAt set — the
2753        // delete-propagation mechanism (doc 40).
2754        let mut r = sample_account();
2755        r.deleted_at = Some("2026-06-20T12:00:00Z".into());
2756        let s = serde_json::to_string(&r).unwrap();
2757        assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
2758    }
2759
2760    #[test]
2761    fn account_record_round_trips_optional_fields() {
2762        // A minimal line — no auth username, server, port, keepalive, or
2763        // tombstone — should parse with those all absent.
2764        let raw = r#"{
2765            "sourceId": "a",
2766            "enabled": false,
2767            "displayName": "Cheap trunk",
2768            "username": "u",
2769            "domain": "d",
2770            "transport": "tcp",
2771            "registerExpires": 120,
2772            "disclosureEnabled": false,
2773            "updatedAt": "2026-06-20T10:00:00Z"
2774        }"#;
2775        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2776        assert!(!parsed.enabled);
2777        assert!(parsed.auth_username.is_none());
2778        assert!(parsed.server.is_none());
2779        assert!(parsed.port.is_none());
2780        assert!(parsed.keepalive_secs.is_none());
2781        assert!(parsed.deleted_at.is_none());
2782        assert_eq!(parsed.transport, VoiceTransport::Tcp);
2783        assert_eq!(parsed.register_expires, 120);
2784    }
2785
2786    #[test]
2787    fn voice_transport_round_trips_via_json() {
2788        for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
2789            let s = serde_json::to_string(&t).unwrap();
2790            let back: VoiceTransport = serde_json::from_str(&s).unwrap();
2791            assert_eq!(t, back);
2792        }
2793        // Pin the wire strings — the daemon's `TransportKind` and the
2794        // platform's Zod enum both depend on these exact tokens.
2795        assert_eq!(
2796            serde_json::to_string(&VoiceTransport::Udp).unwrap(),
2797            "\"udp\""
2798        );
2799        assert_eq!(
2800            serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
2801            "\"tcp\""
2802        );
2803    }
2804
2805    #[test]
2806    fn accounts_query_omits_unset_and_serializes_include_deleted() {
2807        let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
2808        assert_eq!(empty, "{}", "default query should be empty: {empty}");
2809        let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
2810            include_deleted: Some(true),
2811        })
2812        .unwrap();
2813        assert!(
2814            with_deleted.contains("\"includeDeleted\":true"),
2815            "{with_deleted}"
2816        );
2817    }
2818
2819    // ---- VoiceFlows ----
2820
2821    #[test]
2822    fn flows_query_serializes_cursor_and_omits_absent_fields() {
2823        let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
2824        assert_eq!(empty, "{}");
2825        let cursored = serde_json::to_string(&VoiceFlowsQuery {
2826            after: Some("flow_abc".into()),
2827            limit: Some(100),
2828            schema_versions: None,
2829        })
2830        .unwrap();
2831        assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
2832        assert!(cursored.contains("\"limit\":100"), "{cursored}");
2833    }
2834
2835    #[test]
2836    fn flows_query_sends_schema_versions_under_the_servers_name() {
2837        // The struct is camelCase; this parameter is not. A silently
2838        // camelCased key is ignored by the server, which reads exactly
2839        // like an account with no flows in that version — so pin it.
2840        let query = serde_json::to_string(&VoiceFlowsQuery {
2841            schema_versions: Some("1,2".into()),
2842            ..Default::default()
2843        })
2844        .unwrap();
2845        assert_eq!(query, r#"{"schema_versions":"1,2"}"#);
2846    }
2847
2848    // ---- Booking ----
2849
2850    #[test]
2851    fn booking_slots_request_uses_the_routes_snake_case_wire() {
2852        // Unlike the sync resources above, these routes speak snake_case.
2853        // A camelCased body is rejected as a validation error mid-call,
2854        // which the flow can only render as "unavailable".
2855        let body = serde_json::to_string(&BookingSlotsRequest {
2856            source_id: "call_1".into(),
2857            duration_mins: 30,
2858            buffer_mins: 10,
2859            lead_mins: 120,
2860            horizon_days: 14,
2861            schedule: BookingSchedule {
2862                tue: vec![BookingTimeRange {
2863                    open: "09:00".into(),
2864                    close: "17:00".into(),
2865                }],
2866                ..Default::default()
2867            },
2868            timezone: "Pacific/Auckland".into(),
2869            exceptions: Vec::new(),
2870            limit: 3,
2871        })
2872        .unwrap();
2873        assert!(body.contains(r#""source_id":"call_1""#), "{body}");
2874        assert!(body.contains(r#""duration_mins":30"#), "{body}");
2875        assert!(body.contains(r#""timezone":"Pacific/Auckland""#), "{body}");
2876        // Days with no hours, and an empty exception list, stay off the
2877        // wire entirely rather than shipping empty arrays.
2878        assert!(!body.contains("\"mon\""), "{body}");
2879        assert!(!body.contains("exceptions"), "{body}");
2880    }
2881
2882    #[test]
2883    fn booking_slots_response_parses_both_answers() {
2884        let offered: BookingSlotsResponse = serde_json::from_str(
2885            r#"{"slots":[{"start":"2026-08-11T21:00:00Z","end":"2026-08-11T21:30:00Z"}],"timezone":"Pacific/Auckland"}"#,
2886        )
2887        .unwrap();
2888        assert_eq!(offered.slots.len(), 1);
2889        assert_eq!(offered.timezone, "Pacific/Auckland");
2890        assert!(offered.status.is_none());
2891
2892        // The calendar could not be read. Not an error to the caller of
2893        // this crate — the flow has an exit for it.
2894        let down: BookingSlotsResponse =
2895            serde_json::from_str(r#"{"status":"unavailable","reason":"not_connected"}"#).unwrap();
2896        assert!(down.slots.is_empty());
2897        assert_eq!(down.status.as_deref(), Some("unavailable"));
2898        assert_eq!(down.reason.as_deref(), Some("not_connected"));
2899    }
2900
2901    #[test]
2902    fn booking_book_response_parses_every_outcome() {
2903        let booked: BookingBookResponse =
2904            serde_json::from_str(r#"{"status":"booked","start":"2026-08-11T21:00:00Z"}"#).unwrap();
2905        assert_eq!(booked.status, "booked");
2906        assert_eq!(booked.start.as_deref(), Some("2026-08-11T21:00:00Z"));
2907
2908        let taken: BookingBookResponse =
2909            serde_json::from_str(r#"{"status":"slot_taken"}"#).unwrap();
2910        assert_eq!(taken.status, "slot_taken");
2911        assert!(taken.start.is_none());
2912
2913        // A status this build has never heard of still parses: failing
2914        // here would drop a live call over an unknown string.
2915        let future: BookingBookResponse =
2916            serde_json::from_str(r#"{"status":"needs_deposit"}"#).unwrap();
2917        assert_eq!(future.status, "needs_deposit");
2918    }
2919
2920    #[test]
2921    fn flows_page_parses_platform_shape() {
2922        let raw = r#"{
2923            "items": [{
2924                "id": "flow_1",
2925                "name": "Luigi's — after hours",
2926                "version": 3,
2927                "yaml": "schema_version: 1\n",
2928                "publishedAt": "2026-07-13T10:00:00Z"
2929            }],
2930            "nextAfter": null
2931        }"#;
2932        let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
2933        assert_eq!(page.items.len(), 1);
2934        let rec = &page.items[0];
2935        assert_eq!(rec.id, "flow_1");
2936        assert_eq!(rec.version, 3);
2937        assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
2938        assert!(page.next_after.is_none());
2939
2940        // A mid-walk page carries the cursor.
2941        let more: VoiceFlowsPage =
2942            serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
2943        assert_eq!(more.next_after.as_deref(), Some("flow_1"));
2944    }
2945
2946    #[test]
2947    fn flow_assets_manifest_parses_platform_shape() {
2948        // `ref` (a reserved word) maps to `asset_ref`; a null duration is
2949        // accepted (the platform doesn't always know it).
2950        let raw = r#"{
2951            "assets": [{
2952                "ref": "vprompt_ab12cd34",
2953                "format": "ulaw_8000",
2954                "byteSize": 48044,
2955                "durationMs": null,
2956                "contentHash": "9f2c00aa"
2957            }]
2958        }"#;
2959        let page: VoiceFlowAssetsPage = serde_json::from_str(raw).unwrap();
2960        assert_eq!(page.assets.len(), 1);
2961        let asset = &page.assets[0];
2962        assert_eq!(asset.asset_ref, "vprompt_ab12cd34");
2963        assert_eq!(asset.format, "ulaw_8000");
2964        assert_eq!(asset.byte_size, 48044);
2965        assert!(asset.duration_ms.is_none());
2966        assert_eq!(asset.content_hash, "9f2c00aa");
2967
2968        // A text-only version legitimately has no frozen audio.
2969        let empty: VoiceFlowAssetsPage = serde_json::from_str(r#"{ "assets": [] }"#).unwrap();
2970        assert!(empty.assets.is_empty());
2971    }
2972
2973    #[test]
2974    fn account_record_accepts_unknown_extras_for_forward_compat() {
2975        // A newer client shipping a field this platform version lacks a
2976        // column for round-trips via the `extras` envelope.
2977        let raw = r#"{
2978            "sourceId": "a",
2979            "enabled": true,
2980            "displayName": "x",
2981            "username": "u",
2982            "domain": "d",
2983            "transport": "udp",
2984            "registerExpires": 60,
2985            "disclosureEnabled": true,
2986            "updatedAt": "2026-06-20T10:00:00Z",
2987            "schemaVersion": 2,
2988            "extras": { "ringtone": "classic" }
2989        }"#;
2990        let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
2991        assert_eq!(parsed.envelope.schema_version, Some(2));
2992        let extras = parsed.envelope.extras.as_ref().expect("extras present");
2993        assert_eq!(extras["ringtone"], "classic");
2994    }
2995
2996    #[test]
2997    fn system_flow_record_parses_the_platform_shape() {
2998        // The full wire shape as served by the platform's system flow
2999        // endpoint: all fields present including optionals.
3000        let json = r#"{
3001            "id": "flow_voicemail",
3002            "name": "Voicemail",
3003            "description": "A short greeting.",
3004            "language": "en",
3005            "version": 2,
3006            "yaml": "schema_version: 1\n",
3007            "publishedAt": "2026-08-27 01:02:03",
3008            "access": "open",
3009            "systemTags": ["system", "access:open"]
3010        }"#;
3011        let rec: VoiceSystemFlowRecord = serde_json::from_str(json).unwrap();
3012        assert_eq!(rec.id, "flow_voicemail");
3013        assert_eq!(rec.name, "Voicemail");
3014        assert_eq!(rec.description, "A short greeting.");
3015        assert_eq!(rec.language, "en");
3016        assert_eq!(rec.version, 2);
3017        assert_eq!(rec.yaml, "schema_version: 1\n");
3018        assert_eq!(rec.published_at, Some("2026-08-27 01:02:03".into()));
3019        assert_eq!(rec.access, "open");
3020        assert_eq!(rec.system_tags, vec!["system", "access:open"]);
3021    }
3022
3023    #[test]
3024    fn system_flow_record_tolerates_missing_optionals_and_unknown_fields() {
3025        // Older rows or newer platforms: description, publishedAt,
3026        // systemTags may be absent; unknown fields must be ignored
3027        // (forward compat).
3028        let json = r#"{"id":"f","name":"n","language":"en","version":1,"yaml":"y","access":"account","someFutureField":1}"#;
3029        let rec: VoiceSystemFlowRecord = serde_json::from_str(json).unwrap();
3030        assert_eq!(rec.id, "f");
3031        assert_eq!(rec.name, "n");
3032        assert_eq!(rec.description, "");
3033        assert_eq!(rec.language, "en");
3034        assert_eq!(rec.version, 1);
3035        assert_eq!(rec.yaml, "y");
3036        assert!(rec.published_at.is_none());
3037        assert_eq!(rec.access, "account");
3038        assert!(rec.system_tags.is_empty());
3039    }
3040}