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}