Skip to main content

jmap_chat_client/methods/
space.rs

1//! JMAP Chat — Space/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//!   1. Validate arguments (defence-in-depth empty-state guards).
5//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//!   3. Build args JSON with `serde_json::json!({…})`.
7//!   4. Call `build_request(method_name, args, USING_CHAT)`.
8//!   5. Call `self.call_internal(api_url, &req).await?`.
9//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use jmap_types::{Id, PatchObject, State};
12
13use super::{
14    ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse,
15    SpaceAddCategoryInput, SpaceAddChannelInput, SpaceAddMemberInput, SpaceAddRoleInput,
16    SpaceCreateInput, SpaceJoinInput, SpaceJoinResponse, SpacePatch, SpaceQueryInput,
17    SpaceUpdateCategoryInput, SpaceUpdateChannelInput, SpaceUpdateMemberInput,
18    SpaceUpdateRoleInput,
19};
20
21impl super::SessionClient {
22    /// Fetch Space objects by IDs (RFC 8620 §5.1 / JMAP Chat §Space/get).
23    ///
24    /// If `ids` is `None`, the server returns all Spaces for the account,
25    /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
26    /// For production use, scope the result set via the corresponding
27    /// /query method first and pass explicit ids here to avoid
28    /// `requestTooLarge` errors when the account holds more objects
29    /// than the cap.
30    /// Pass `properties: None` to return all fields.
31    ///
32    /// # Errors
33    ///
34    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
35    ///   if the bound session has no primary account for
36    ///   `urn:ietf:params:jmap:chat`.
37    /// - Any transport / protocol variant returned by
38    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
39    ///   [`Http`](jmap_base_client::ClientError::Http),
40    ///   [`Parse`](jmap_base_client::ClientError::Parse),
41    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
42    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
43    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
44    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
45    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
46    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
47    ///   or
48    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
49    pub async fn space_get(
50        &self,
51        ids: Option<&[Id]>,
52        properties: Option<&[&str]>,
53    ) -> Result<GetResponse<jmap_chat_types::Space>, jmap_base_client::ClientError> {
54        let (api_url, account_id) = self.session_parts()?;
55        // Omit `ids` / `properties` when None — see the matching comment on
56        // `chat_get` for the rationale (consistent with set/changes/query).
57        let mut args = serde_json::json!({ "accountId": account_id });
58        if let Some(id_slice) = ids {
59            args["ids"] = serde_json::to_value(id_slice)
60                .map_err(jmap_base_client::ClientError::from_parse)?;
61        }
62        if let Some(props) = properties {
63            args["properties"] =
64                serde_json::to_value(props).map_err(jmap_base_client::ClientError::from_parse)?;
65        }
66        let req = super::build_request("Space/get", args, super::USING_CHAT);
67        let resp = self.call_internal(api_url, &req).await?;
68        jmap_base_client::extract_response(&resp, super::CALL_ID)
69    }
70
71    /// Fetch changes to Space objects since `since_state` (RFC 8620 §5.2 / Space/changes).
72    ///
73    /// If `has_more_changes` is true in the response, call again with `new_state`
74    /// as `since_state` until the flag is false.
75    ///
76    /// # Errors
77    ///
78    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
79    ///   if `since_state` is the empty string (defence-in-depth —
80    ///   `State` constructed via [`State::from`](jmap_types::State::from)
81    ///   accepts empty strings, but an empty `sinceState` is never
82    ///   useful and would otherwise generate a wasted round-trip).
83    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
84    ///   if the bound session has no primary account for
85    ///   `urn:ietf:params:jmap:chat`.
86    /// - Any transport / protocol variant returned by
87    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
88    ///   the matching error list on [`Self::space_get`].
89    pub async fn space_changes(
90        &self,
91        since_state: &State,
92        max_changes: Option<u64>,
93    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
94        // Defence-in-depth: see `chat_changes`.
95        if since_state.as_ref().is_empty() {
96            return Err(jmap_base_client::ClientError::InvalidArgument(
97                "space_changes: since_state may not be empty".into(),
98            ));
99        }
100        let (api_url, account_id) = self.session_parts()?;
101        let mut args = serde_json::json!({
102            "accountId": account_id,
103            "sinceState": since_state,
104        });
105        if let Some(mc) = max_changes {
106            args["maxChanges"] = mc.into();
107        }
108        let req = super::build_request("Space/changes", args, super::USING_CHAT);
109        let resp = self.call_internal(api_url, &req).await?;
110        jmap_base_client::extract_response(&resp, super::CALL_ID)
111    }
112
113    /// Destroy Space objects (RFC 8620 §5.3 / Space/set destroy).
114    ///
115    /// Permanently removes the listed Space IDs from the account.
116    /// `ids` must be non-empty; the guard fires before any network call.
117    ///
118    /// # Errors
119    ///
120    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
121    ///   if `ids` is empty (caller-precondition guard).
122    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
123    ///   if the bound session has no primary account for
124    ///   `urn:ietf:params:jmap:chat`.
125    /// - Any transport / protocol variant returned by
126    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
127    ///   the matching error list on [`Self::space_get`]. /set destroy
128    ///   errors appear in [`SetResponse::not_destroyed`] rather than
129    ///   as [`Err`].
130    pub async fn space_destroy(
131        &self,
132        ids: &[Id],
133    ) -> Result<SetResponse, jmap_base_client::ClientError> {
134        if ids.is_empty() {
135            return Err(jmap_base_client::ClientError::InvalidArgument(
136                "space_destroy: ids may not be empty".into(),
137            ));
138        }
139        let (api_url, account_id) = self.session_parts()?;
140        let args = serde_json::json!({
141            "accountId": account_id,
142            "destroy": ids,
143        });
144        let req = super::build_request("Space/set", args, super::USING_CHAT);
145        let resp = self.call_internal(api_url, &req).await?;
146        jmap_base_client::extract_response(&resp, super::CALL_ID)
147    }
148
149    /// Query Space IDs with optional filter (RFC 8620 §5.5 / JMAP Chat §Space/query).
150    ///
151    /// Only keys that are `Some` in `input` are included in the filter object;
152    /// an empty filter is sent as JSON `null`.
153    ///
154    /// # Errors
155    ///
156    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
157    ///   if the bound session has no primary account for
158    ///   `urn:ietf:params:jmap:chat`.
159    /// - Any transport / protocol variant returned by
160    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
161    ///   the matching error list on [`Self::space_get`]. RFC 8620 §5.5
162    ///   defines additional /query method-level errors
163    ///   (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
164    ///   `tooManyChanges`) that surface as
165    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
166    pub async fn space_query(
167        &self,
168        input: &SpaceQueryInput<'_>,
169    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
170        let (api_url, account_id) = self.session_parts()?;
171        let mut filter = serde_json::Map::new();
172        if let Some(n) = input.filter_name {
173            filter.insert("name".into(), n.into());
174        }
175        if let Some(p) = input.filter_is_public {
176            filter.insert("isPublic".into(), p.into());
177        }
178        let filter_val = if filter.is_empty() {
179            serde_json::Value::Null
180        } else {
181            serde_json::Value::Object(filter)
182        };
183        let mut args = serde_json::json!({
184            "accountId": account_id,
185            "filter": filter_val,
186        });
187        if let Some(p) = input.position {
188            args["position"] = p.into();
189        }
190        if let Some(l) = input.limit {
191            args["limit"] = l.into();
192        }
193        let req = super::build_request("Space/query", args, super::USING_CHAT);
194        let resp = self.call_internal(api_url, &req).await?;
195        jmap_base_client::extract_response(&resp, super::CALL_ID)
196    }
197
198    /// Fetch query-result changes for Space since `since_query_state`
199    /// (RFC 8620 §5.6 / Space/queryChanges).
200    ///
201    /// Returns which Space IDs were removed from or added to the query result set
202    /// since the given state. `max_changes` may be `None`.
203    ///
204    /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
205    /// original `Space/query` call that returned `since_query_state` —
206    /// RFC 8620 §5.6 is explicit that the server uses them to compute
207    /// which entries entered or left the result set.
208    ///
209    /// `up_to_id` is the highest-index id the client has cached;
210    /// `calculate_total` requests the new total result count.
211    ///
212    /// # Errors
213    ///
214    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
215    ///   if `since_query_state` is the empty string (defence-in-depth
216    ///   empty-state guard; see [`Self::space_changes`]).
217    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
218    ///   if the bound session has no primary account for
219    ///   `urn:ietf:params:jmap:chat`.
220    /// - Any transport / protocol variant returned by
221    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
222    ///   the matching error list on [`Self::space_get`]. RFC 8620 §5.6
223    ///   also defines `cannotCalculateChanges` (returned when the
224    ///   server cannot honour the request given the supplied filter /
225    ///   sort); it surfaces as
226    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
227    pub async fn space_query_changes(
228        &self,
229        since_query_state: &State,
230        max_changes: Option<u64>,
231        filter: Option<serde_json::Value>,
232        sort: Option<serde_json::Value>,
233        up_to_id: Option<&Id>,
234        calculate_total: Option<bool>,
235    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
236        // Defence-in-depth: see `chat_changes`.
237        if since_query_state.as_ref().is_empty() {
238            return Err(jmap_base_client::ClientError::InvalidArgument(
239                "space_query_changes: since_query_state may not be empty".into(),
240            ));
241        }
242        let (api_url, account_id) = self.session_parts()?;
243        let mut args = serde_json::json!({
244            "accountId": account_id,
245            "sinceQueryState": since_query_state,
246        });
247        if let Some(f) = filter {
248            args["filter"] = f;
249        }
250        if let Some(s) = sort {
251            args["sort"] = s;
252        }
253        if let Some(mc) = max_changes {
254            args["maxChanges"] = mc.into();
255        }
256        if let Some(uti) = up_to_id {
257            args["upToId"] =
258                serde_json::to_value(uti).map_err(jmap_base_client::ClientError::from_parse)?;
259        }
260        if let Some(ct) = calculate_total {
261            args["calculateTotal"] = ct.into();
262        }
263        let req = super::build_request("Space/queryChanges", args, super::USING_CHAT);
264        let resp = self.call_internal(api_url, &req).await?;
265        jmap_base_client::extract_response(&resp, super::CALL_ID)
266    }
267
268    /// Create a new Space (JMAP Chat §Space/set create).
269    ///
270    /// When `input.client_id` is `None`, a ULID is generated automatically.
271    /// The server maps the creation key to the server-assigned Space id in
272    /// `SetResponse.created`.
273    ///
274    /// # Errors
275    ///
276    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
277    ///   if `input.name` is empty (caller-precondition guard — Space
278    ///   names cannot be empty).
279    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
280    ///   if the bound session has no primary account for
281    ///   `urn:ietf:params:jmap:chat`.
282    /// - Any transport / protocol variant returned by
283    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
284    ///   the matching error list on [`Self::space_get`]. /set create
285    ///   errors (e.g. `invalidProperties`, `forbidden`, `overQuota`)
286    ///   appear in [`SetResponse::not_created`] rather than as
287    ///   [`Err`].
288    pub async fn space_create(
289        &self,
290        input: &SpaceCreateInput<'_>,
291    ) -> Result<SetResponse, jmap_base_client::ClientError> {
292        if input.name.is_empty() {
293            return Err(jmap_base_client::ClientError::InvalidArgument(
294                "space_create: name may not be empty".into(),
295            ));
296        }
297        let (api_url, account_id) = self.session_parts()?;
298        let client_id = super::resolve_client_id(input.client_id);
299        let mut create_obj = serde_json::json!({ "name": input.name });
300        if let Some(d) = input.description {
301            create_obj["description"] = d.into();
302        }
303        if let Some(b) = input.icon_blob_id {
304            create_obj["iconBlobId"] = b.as_ref().into();
305        }
306        let args = serde_json::json!({
307            "accountId": account_id,
308            "create": { client_id: create_obj },
309        });
310        let req = super::build_request("Space/set", args, super::USING_CHAT);
311        let resp = self.call_internal(api_url, &req).await?;
312        jmap_base_client::extract_response(&resp, super::CALL_ID)
313    }
314
315    /// Join a Space via invite code or direct ID (JMAP Chat §Space/join).
316    ///
317    /// `input` selects exactly one join path; the enum makes invalid inputs
318    /// unrepresentable at the type level.
319    ///
320    /// # Errors
321    ///
322    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
323    ///   if `input` is `SpaceJoinInput::InviteCode("")` (empty code is
324    ///   never useful and `Id::new_validated("")` is rejected at type
325    ///   construction for the other variant).
326    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
327    ///   if the bound session has no primary account for
328    ///   `urn:ietf:params:jmap:chat`.
329    /// - Any transport / protocol variant returned by
330    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
331    ///   the matching error list on [`Self::space_get`]. Server-side
332    ///   rejections (`invalidArguments` for unknown invite codes,
333    ///   `forbidden` for non-public Spaces) surface as
334    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
335    pub async fn space_join(
336        &self,
337        input: &SpaceJoinInput<'_>,
338    ) -> Result<SpaceJoinResponse, jmap_base_client::ClientError> {
339        let (api_url, account_id) = self.session_parts()?;
340        let mut args = serde_json::json!({ "accountId": account_id });
341        match input {
342            SpaceJoinInput::InviteCode(ic) => {
343                if ic.is_empty() {
344                    return Err(jmap_base_client::ClientError::InvalidArgument(
345                        "space_join: invite_code may not be empty".into(),
346                    ));
347                }
348                args["inviteCode"] = (*ic).into();
349            }
350            SpaceJoinInput::SpaceId(sid) => {
351                args["spaceId"] = sid.as_ref().into();
352            }
353        }
354        let req = super::build_request("Space/join", args, super::USING_CHAT);
355        let resp = self.call_internal(api_url, &req).await?;
356        jmap_base_client::extract_response(&resp, super::CALL_ID)
357    }
358
359    /// Update Space properties (JMAP Chat §Space/set update).
360    ///
361    /// Issues an `update` operation patching only the fields present in `patch`.
362    /// Use `Patch::Set(v)` to set nullable fields, `Patch::Clear` to null-clear
363    /// them, and `Patch::Keep` (default) to leave them unchanged. Slice fields
364    /// default to `None` for no-change.
365    ///
366    /// Supports the full set of semantic mutation keys from JMAP Chat §Space/set:
367    /// metadata (`name`, `description`, `iconBlobId`, `isPublic`,
368    /// `isPubliclyPreviewable`); members (`addMembers`, `removeMembers`,
369    /// `updateMembers`, `manage_members`); channels (`addChannels`,
370    /// `removeChannels`, `updateChannels`, `manage_channels`); roles
371    /// (`addRoles`, `removeRoles`, `updateRoles`, `manage_roles`); and
372    /// categories (`addCategories`, `removeCategories`, `updateCategories`,
373    /// `manage_channels`).
374    ///
375    /// # Errors
376    ///
377    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
378    ///   if any new channel, role, or category in the patch has an
379    ///   empty `name` (caller-precondition guards on `add_channels`,
380    ///   `add_roles`, `add_categories`).
381    /// - [`ClientError::Parse`](jmap_base_client::ClientError::Parse) if
382    ///   serializing a typed sub-field (a `Clearable` entry on
383    ///   `description` / `icon_blob_id` / `update_*.*` / role color, a
384    ///   typed `permission_overrides` value, etc.) fails (pathological
385    ///   conditions only).
386    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
387    ///   if the bound session has no primary account for
388    ///   `urn:ietf:params:jmap:chat`.
389    /// - Any transport / protocol variant returned by
390    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
391    ///   the matching error list on [`Self::space_get`]. /set update
392    ///   errors appear in [`SetResponse::not_updated`] rather than as
393    ///   [`Err`].
394    pub async fn space_update(
395        &self,
396        id: &Id,
397        patch: &SpacePatch<'_>,
398    ) -> Result<SetResponse, jmap_base_client::ClientError> {
399        let (api_url, account_id) = self.session_parts()?;
400        let mut patch_map = serde_json::Map::new();
401
402        if let Some(n) = patch.name {
403            patch_map.insert("name".into(), n.into());
404        }
405        if let Some(entry) = patch
406            .description
407            .map_entry()
408            .map_err(jmap_base_client::ClientError::from_parse)?
409        {
410            patch_map.insert("description".into(), entry);
411        }
412        if let Some(entry) = patch
413            .icon_blob_id
414            .map_entry()
415            .map_err(jmap_base_client::ClientError::from_parse)?
416        {
417            patch_map.insert("iconBlobId".into(), entry);
418        }
419        if let Some(ip) = patch.is_public {
420            patch_map.insert("isPublic".into(), ip.into());
421        }
422        if let Some(ipp) = patch.is_publicly_previewable {
423            patch_map.insert("isPubliclyPreviewable".into(), ipp.into());
424        }
425        if let Some(members) = patch.add_members {
426            if !members.is_empty() {
427                let arr: Vec<serde_json::Value> = members
428                    .iter()
429                    .map(
430                        |m: &SpaceAddMemberInput<'_>| -> Result<
431                            serde_json::Value,
432                            jmap_base_client::ClientError,
433                        > {
434                            let mut obj = serde_json::json!({ "id": m.id });
435                            if let Some(role_ids) = m.role_ids {
436                                obj["roleIds"] = serde_json::to_value(role_ids)
437                                    .map_err(jmap_base_client::ClientError::from_parse)?;
438                            }
439                            Ok(obj)
440                        },
441                    )
442                    .collect::<Result<Vec<_>, _>>()?;
443                patch_map.insert("addMembers".into(), serde_json::Value::Array(arr));
444            }
445        }
446        if let Some(rm) = patch.remove_members {
447            if !rm.is_empty() {
448                patch_map.insert(
449                    "removeMembers".into(),
450                    serde_json::to_value(rm).map_err(jmap_base_client::ClientError::from_parse)?,
451                );
452            }
453        }
454        if let Some(um) = patch.update_members {
455            if !um.is_empty() {
456                let arr: Vec<serde_json::Value> = um
457                    .iter()
458                    .map(
459                        |u: &SpaceUpdateMemberInput<'_>| -> Result<
460                            serde_json::Value,
461                            jmap_base_client::ClientError,
462                        > {
463                            let mut obj = serde_json::json!({ "id": u.id });
464                            if let Some(role_ids) = u.role_ids {
465                                obj["roleIds"] = serde_json::to_value(role_ids)
466                                    .map_err(jmap_base_client::ClientError::from_parse)?;
467                            }
468                            if let Some(entry) = u
469                                .nick
470                                .map_entry()
471                                .map_err(jmap_base_client::ClientError::from_parse)?
472                            {
473                                obj["nick"] = entry;
474                            }
475                            Ok(obj)
476                        },
477                    )
478                    .collect::<Result<Vec<_>, _>>()?;
479                patch_map.insert("updateMembers".into(), serde_json::Value::Array(arr));
480            }
481        }
482        if let Some(ac) = patch.add_channels {
483            if !ac.is_empty() {
484                let arr = ac
485                    .iter()
486                    .map(|c: &SpaceAddChannelInput<'_>| {
487                        if c.name.is_empty() {
488                            return Err(jmap_base_client::ClientError::InvalidArgument(
489                                "space_update: channel name may not be empty".into(),
490                            ));
491                        }
492                        let mut obj = serde_json::json!({ "name": c.name });
493                        if let Some(cat) = c.category_id {
494                            obj["categoryId"] = cat.as_ref().into();
495                        }
496                        if let Some(pos) = c.position {
497                            obj["position"] = pos.into();
498                        }
499                        if let Some(t) = c.topic {
500                            obj["topic"] = t.into();
501                        }
502                        Ok(obj)
503                    })
504                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
505                patch_map.insert("addChannels".into(), serde_json::Value::Array(arr));
506            }
507        }
508        if let Some(rc) = patch.remove_channels {
509            if !rc.is_empty() {
510                patch_map.insert(
511                    "removeChannels".into(),
512                    serde_json::to_value(rc).map_err(jmap_base_client::ClientError::from_parse)?,
513                );
514            }
515        }
516        if let Some(uc) = patch.update_channels {
517            if !uc.is_empty() {
518                let arr = uc
519                    .iter()
520                    .map(|c: &SpaceUpdateChannelInput<'_>| {
521                        let mut obj = serde_json::json!({ "id": c.id });
522                        if let Some(n) = c.name {
523                            obj["name"] = n.into();
524                        }
525                        if let Some(entry) = c
526                            .topic
527                            .map_entry()
528                            .map_err(jmap_base_client::ClientError::from_parse)?
529                        {
530                            obj["topic"] = entry;
531                        }
532                        if let Some(entry) = c
533                            .category_id
534                            .map_entry()
535                            .map_err(jmap_base_client::ClientError::from_parse)?
536                        {
537                            obj["categoryId"] = entry;
538                        }
539                        if let Some(p) = c.position {
540                            obj["position"] = p.into();
541                        }
542                        if let Some(s) = c.slow_mode_seconds {
543                            obj["slowModeSeconds"] = s.into();
544                        }
545                        if let Some(po) = c.permission_overrides {
546                            obj["permissionOverrides"] = serde_json::to_value(po)
547                                .map_err(jmap_base_client::ClientError::from_parse)?;
548                        }
549                        Ok(obj)
550                    })
551                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
552                patch_map.insert("updateChannels".into(), serde_json::Value::Array(arr));
553            }
554        }
555        if let Some(ar) = patch.add_roles {
556            if !ar.is_empty() {
557                let arr = ar
558                    .iter()
559                    .map(|r: &SpaceAddRoleInput<'_>| {
560                        if r.name.is_empty() {
561                            return Err(jmap_base_client::ClientError::InvalidArgument(
562                                "space_update: role name may not be empty".into(),
563                            ));
564                        }
565                        let mut obj = serde_json::json!({
566                            "name": r.name,
567                            "permissions": r.permissions,
568                            "position": r.position,
569                        });
570                        if let Some(c) = r.color {
571                            obj["color"] = c.into();
572                        }
573                        Ok(obj)
574                    })
575                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
576                patch_map.insert("addRoles".into(), serde_json::Value::Array(arr));
577            }
578        }
579        if let Some(rr) = patch.remove_roles {
580            if !rr.is_empty() {
581                patch_map.insert(
582                    "removeRoles".into(),
583                    serde_json::to_value(rr).map_err(jmap_base_client::ClientError::from_parse)?,
584                );
585            }
586        }
587        if let Some(ur) = patch.update_roles {
588            if !ur.is_empty() {
589                let arr = ur
590                    .iter()
591                    .map(|r: &SpaceUpdateRoleInput<'_>| {
592                        let mut obj = serde_json::json!({ "id": r.id });
593                        if let Some(n) = r.name {
594                            obj["name"] = n.into();
595                        }
596                        if let Some(entry) = r
597                            .color
598                            .map_entry()
599                            .map_err(jmap_base_client::ClientError::from_parse)?
600                        {
601                            obj["color"] = entry;
602                        }
603                        if let Some(perms) = r.permissions {
604                            obj["permissions"] = serde_json::Value::Array(
605                                perms.iter().copied().map(serde_json::Value::from).collect(),
606                            );
607                        }
608                        if let Some(p) = r.position {
609                            obj["position"] = p.into();
610                        }
611                        Ok(obj)
612                    })
613                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
614                patch_map.insert("updateRoles".into(), serde_json::Value::Array(arr));
615            }
616        }
617        if let Some(ac) = patch.add_categories {
618            if !ac.is_empty() {
619                let arr = ac
620                    .iter()
621                    .map(|c: &SpaceAddCategoryInput<'_>| {
622                        if c.name.is_empty() {
623                            return Err(jmap_base_client::ClientError::InvalidArgument(
624                                "space_update: category name may not be empty".into(),
625                            ));
626                        }
627                        let mut obj = serde_json::json!({ "name": c.name });
628                        if let Some(p) = c.position {
629                            obj["position"] = p.into();
630                        }
631                        if let Some(cids) = c.channel_ids {
632                            obj["channelIds"] = serde_json::to_value(cids)
633                                .map_err(jmap_base_client::ClientError::from_parse)?;
634                        }
635                        Ok(obj)
636                    })
637                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
638                patch_map.insert("addCategories".into(), serde_json::Value::Array(arr));
639            }
640        }
641        if let Some(rc) = patch.remove_categories {
642            if !rc.is_empty() {
643                patch_map.insert(
644                    "removeCategories".into(),
645                    serde_json::to_value(rc).map_err(jmap_base_client::ClientError::from_parse)?,
646                );
647            }
648        }
649        if let Some(uc) = patch.update_categories {
650            if !uc.is_empty() {
651                let arr = uc
652                    .iter()
653                    .map(|c: &SpaceUpdateCategoryInput<'_>| {
654                        let mut obj = serde_json::json!({ "id": c.id });
655                        if let Some(n) = c.name {
656                            obj["name"] = n.into();
657                        }
658                        if let Some(p) = c.position {
659                            obj["position"] = p.into();
660                        }
661                        if let Some(cids) = c.channel_ids {
662                            obj["channelIds"] = serde_json::to_value(cids)
663                                .map_err(jmap_base_client::ClientError::from_parse)?;
664                        }
665                        Ok(obj)
666                    })
667                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
668                patch_map.insert("updateCategories".into(), serde_json::Value::Array(arr));
669            }
670        }
671
672        // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
673        // serializing. Wire bytes are unchanged because PatchObject is
674        // #[serde(transparent)]; the typed boundary documents the contract.
675        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
676        let args = serde_json::json!({
677            "accountId": account_id,
678            "update": { id.as_ref(): patch_value },
679        });
680        let req = super::build_request("Space/set", args, super::USING_CHAT);
681        let resp = self.call_internal(api_url, &req).await?;
682        jmap_base_client::extract_response(&resp, super::CALL_ID)
683    }
684}