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    /// Pass `properties: None` to return all fields.
26    pub async fn space_get(
27        &self,
28        ids: Option<&[Id]>,
29        properties: Option<&[&str]>,
30    ) -> Result<GetResponse<jmap_chat_types::Space>, jmap_base_client::ClientError> {
31        let (api_url, account_id) = self.session_parts()?;
32        // Omit `ids` / `properties` when None — see the matching comment on
33        // `chat_get` for the rationale (consistent with set/changes/query).
34        let mut args = serde_json::json!({ "accountId": account_id });
35        if let Some(id_slice) = ids {
36            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
37        }
38        if let Some(props) = properties {
39            args["properties"] = serde_json::Value::Array(
40                props.iter().copied().map(serde_json::Value::from).collect(),
41            );
42        }
43        let req = super::build_request("Space/get", args, super::USING_CHAT);
44        let resp = self.call_internal(api_url, &req).await?;
45        jmap_base_client::extract_response(&resp, super::CALL_ID)
46    }
47
48    /// Fetch changes to Space objects since `since_state` (RFC 8620 §5.2 / Space/changes).
49    ///
50    /// If `has_more_changes` is true in the response, call again with `new_state`
51    /// as `since_state` until the flag is false.
52    pub async fn space_changes(
53        &self,
54        since_state: &State,
55        max_changes: Option<u64>,
56    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
57        // Defence-in-depth: see `chat_changes`.
58        if since_state.as_ref().is_empty() {
59            return Err(jmap_base_client::ClientError::InvalidArgument(
60                "space_changes: since_state may not be empty".into(),
61            ));
62        }
63        let (api_url, account_id) = self.session_parts()?;
64        let mut args = serde_json::json!({
65            "accountId": account_id,
66            "sinceState": since_state,
67        });
68        if let Some(mc) = max_changes {
69            args["maxChanges"] = mc.into();
70        }
71        let req = super::build_request("Space/changes", args, super::USING_CHAT);
72        let resp = self.call_internal(api_url, &req).await?;
73        jmap_base_client::extract_response(&resp, super::CALL_ID)
74    }
75
76    /// Destroy Space objects (RFC 8620 §5.3 / Space/set destroy).
77    ///
78    /// Permanently removes the listed Space IDs from the account.
79    /// `ids` must be non-empty; the guard fires before any network call.
80    pub async fn space_destroy(
81        &self,
82        ids: &[Id],
83    ) -> Result<SetResponse, jmap_base_client::ClientError> {
84        if ids.is_empty() {
85            return Err(jmap_base_client::ClientError::InvalidArgument(
86                "space_destroy: ids may not be empty".into(),
87            ));
88        }
89        let (api_url, account_id) = self.session_parts()?;
90        let args = serde_json::json!({
91            "accountId": account_id,
92            "destroy": ids,
93        });
94        let req = super::build_request("Space/set", args, super::USING_CHAT);
95        let resp = self.call_internal(api_url, &req).await?;
96        jmap_base_client::extract_response(&resp, super::CALL_ID)
97    }
98
99    /// Query Space IDs with optional filter (RFC 8620 §5.5 / JMAP Chat §Space/query).
100    ///
101    /// Only keys that are `Some` in `input` are included in the filter object;
102    /// an empty filter is sent as JSON `null`.
103    pub async fn space_query(
104        &self,
105        input: &SpaceQueryInput<'_>,
106    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
107        let (api_url, account_id) = self.session_parts()?;
108        let mut filter = serde_json::Map::new();
109        if let Some(n) = input.filter_name {
110            filter.insert("name".into(), n.into());
111        }
112        if let Some(p) = input.filter_is_public {
113            filter.insert("isPublic".into(), p.into());
114        }
115        let filter_val = if filter.is_empty() {
116            serde_json::Value::Null
117        } else {
118            serde_json::Value::Object(filter)
119        };
120        let mut args = serde_json::json!({
121            "accountId": account_id,
122            "filter": filter_val,
123        });
124        if let Some(p) = input.position {
125            args["position"] = p.into();
126        }
127        if let Some(l) = input.limit {
128            args["limit"] = l.into();
129        }
130        let req = super::build_request("Space/query", args, super::USING_CHAT);
131        let resp = self.call_internal(api_url, &req).await?;
132        jmap_base_client::extract_response(&resp, super::CALL_ID)
133    }
134
135    /// Fetch query-result changes for Space since `since_query_state`
136    /// (RFC 8620 §5.6 / Space/queryChanges).
137    ///
138    /// Returns which Space IDs were removed from or added to the query result set
139    /// since the given state. `max_changes` may be `None`.
140    pub async fn space_query_changes(
141        &self,
142        since_query_state: &State,
143        max_changes: Option<u64>,
144    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
145        // Defence-in-depth: see `chat_changes`.
146        if since_query_state.as_ref().is_empty() {
147            return Err(jmap_base_client::ClientError::InvalidArgument(
148                "space_query_changes: since_query_state may not be empty".into(),
149            ));
150        }
151        let (api_url, account_id) = self.session_parts()?;
152        let mut args = serde_json::json!({
153            "accountId": account_id,
154            "sinceQueryState": since_query_state,
155        });
156        if let Some(mc) = max_changes {
157            args["maxChanges"] = mc.into();
158        }
159        let req = super::build_request("Space/queryChanges", args, super::USING_CHAT);
160        let resp = self.call_internal(api_url, &req).await?;
161        jmap_base_client::extract_response(&resp, super::CALL_ID)
162    }
163
164    /// Create a new Space (JMAP Chat §Space/set create).
165    ///
166    /// When `input.client_id` is `None`, a ULID is generated automatically.
167    /// The server maps the creation key to the server-assigned Space id in
168    /// `SetResponse.created`.
169    pub async fn space_create(
170        &self,
171        input: &SpaceCreateInput<'_>,
172    ) -> Result<SetResponse, jmap_base_client::ClientError> {
173        if input.name.is_empty() {
174            return Err(jmap_base_client::ClientError::InvalidArgument(
175                "space_create: name may not be empty".into(),
176            ));
177        }
178        let (api_url, account_id) = self.session_parts()?;
179        let client_id = super::resolve_client_id(input.client_id);
180        let mut create_obj = serde_json::json!({ "name": input.name });
181        if let Some(d) = input.description {
182            create_obj["description"] = d.into();
183        }
184        if let Some(b) = input.icon_blob_id {
185            create_obj["iconBlobId"] = b.as_ref().into();
186        }
187        let args = serde_json::json!({
188            "accountId": account_id,
189            "create": { client_id: create_obj },
190        });
191        let req = super::build_request("Space/set", args, super::USING_CHAT);
192        let resp = self.call_internal(api_url, &req).await?;
193        jmap_base_client::extract_response(&resp, super::CALL_ID)
194    }
195
196    /// Join a Space via invite code or direct ID (JMAP Chat §Space/join).
197    ///
198    /// `input` selects exactly one join path; the enum makes invalid inputs
199    /// unrepresentable at the type level.
200    pub async fn space_join(
201        &self,
202        input: &SpaceJoinInput<'_>,
203    ) -> Result<SpaceJoinResponse, jmap_base_client::ClientError> {
204        let (api_url, account_id) = self.session_parts()?;
205        let mut args = serde_json::json!({ "accountId": account_id });
206        match input {
207            SpaceJoinInput::InviteCode(ic) => {
208                if ic.is_empty() {
209                    return Err(jmap_base_client::ClientError::InvalidArgument(
210                        "space_join: invite_code may not be empty".into(),
211                    ));
212                }
213                args["inviteCode"] = (*ic).into();
214            }
215            SpaceJoinInput::SpaceId(sid) => {
216                args["spaceId"] = sid.as_ref().into();
217            }
218        }
219        let req = super::build_request("Space/join", args, super::USING_CHAT);
220        let resp = self.call_internal(api_url, &req).await?;
221        jmap_base_client::extract_response(&resp, super::CALL_ID)
222    }
223
224    /// Update Space properties (JMAP Chat §Space/set update).
225    ///
226    /// Issues an `update` operation patching only the fields present in `patch`.
227    /// Use `Patch::Set(v)` to set nullable fields, `Patch::Clear` to null-clear
228    /// them, and `Patch::Keep` (default) to leave them unchanged. Slice fields
229    /// default to `None` for no-change.
230    ///
231    /// Supports the full set of semantic mutation keys from JMAP Chat §Space/set:
232    /// metadata (`name`, `description`, `iconBlobId`, `isPublic`,
233    /// `isPubliclyPreviewable`); members (`addMembers`, `removeMembers`,
234    /// `updateMembers`, `manage_members`); channels (`addChannels`,
235    /// `removeChannels`, `updateChannels`, `manage_channels`); roles
236    /// (`addRoles`, `removeRoles`, `updateRoles`, `manage_roles`); and
237    /// categories (`addCategories`, `removeCategories`, `updateCategories`,
238    /// `manage_channels`).
239    pub async fn space_update(
240        &self,
241        id: &Id,
242        patch: &SpacePatch<'_>,
243    ) -> Result<SetResponse, jmap_base_client::ClientError> {
244        let (api_url, account_id) = self.session_parts()?;
245        let mut patch_map = serde_json::Map::new();
246
247        if let Some(n) = patch.name {
248            patch_map.insert("name".into(), n.into());
249        }
250        if let Some(entry) = patch
251            .description
252            .map_entry()
253            .map_err(jmap_base_client::ClientError::Parse)?
254        {
255            patch_map.insert("description".into(), entry);
256        }
257        if let Some(entry) = patch
258            .icon_blob_id
259            .map_entry()
260            .map_err(jmap_base_client::ClientError::Parse)?
261        {
262            patch_map.insert("iconBlobId".into(), entry);
263        }
264        if let Some(ip) = patch.is_public {
265            patch_map.insert("isPublic".into(), ip.into());
266        }
267        if let Some(ipp) = patch.is_publicly_previewable {
268            patch_map.insert("isPubliclyPreviewable".into(), ipp.into());
269        }
270        if let Some(members) = patch.add_members {
271            if !members.is_empty() {
272                let arr: Vec<serde_json::Value> = members
273                    .iter()
274                    .map(
275                        |m: &SpaceAddMemberInput<'_>| -> Result<
276                            serde_json::Value,
277                            jmap_base_client::ClientError,
278                        > {
279                            let mut obj = serde_json::json!({ "id": m.id });
280                            if let Some(role_ids) = m.role_ids {
281                                obj["roleIds"] = serde_json::to_value(role_ids)
282                                    .expect("Id slice Serialize is infallible");
283                            }
284                            Ok(obj)
285                        },
286                    )
287                    .collect::<Result<Vec<_>, _>>()?;
288                patch_map.insert("addMembers".into(), serde_json::Value::Array(arr));
289            }
290        }
291        if let Some(rm) = patch.remove_members {
292            if !rm.is_empty() {
293                patch_map.insert(
294                    "removeMembers".into(),
295                    serde_json::to_value(rm).expect("Id slice Serialize is infallible"),
296                );
297            }
298        }
299        if let Some(um) = patch.update_members {
300            if !um.is_empty() {
301                let arr: Vec<serde_json::Value> = um
302                    .iter()
303                    .map(
304                        |u: &SpaceUpdateMemberInput<'_>| -> Result<
305                            serde_json::Value,
306                            jmap_base_client::ClientError,
307                        > {
308                            let mut obj = serde_json::json!({ "id": u.id });
309                            if let Some(role_ids) = u.role_ids {
310                                obj["roleIds"] = serde_json::to_value(role_ids)
311                                    .expect("Id slice Serialize is infallible");
312                            }
313                            if let Some(entry) = u
314                                .nick
315                                .map_entry()
316                                .map_err(jmap_base_client::ClientError::Parse)?
317                            {
318                                obj["nick"] = entry;
319                            }
320                            Ok(obj)
321                        },
322                    )
323                    .collect::<Result<Vec<_>, _>>()?;
324                patch_map.insert("updateMembers".into(), serde_json::Value::Array(arr));
325            }
326        }
327        if let Some(ac) = patch.add_channels {
328            if !ac.is_empty() {
329                let arr = ac
330                    .iter()
331                    .map(|c: &SpaceAddChannelInput<'_>| {
332                        if c.name.is_empty() {
333                            return Err(jmap_base_client::ClientError::InvalidArgument(
334                                "space_update: channel name may not be empty".into(),
335                            ));
336                        }
337                        let mut obj = serde_json::json!({ "name": c.name });
338                        if let Some(cat) = c.category_id {
339                            obj["categoryId"] = cat.as_ref().into();
340                        }
341                        if let Some(pos) = c.position {
342                            obj["position"] = pos.into();
343                        }
344                        if let Some(t) = c.topic {
345                            obj["topic"] = t.into();
346                        }
347                        Ok(obj)
348                    })
349                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
350                patch_map.insert("addChannels".into(), serde_json::Value::Array(arr));
351            }
352        }
353        if let Some(rc) = patch.remove_channels {
354            if !rc.is_empty() {
355                patch_map.insert(
356                    "removeChannels".into(),
357                    serde_json::to_value(rc).expect("Id slice Serialize is infallible"),
358                );
359            }
360        }
361        if let Some(uc) = patch.update_channels {
362            if !uc.is_empty() {
363                let arr = uc
364                    .iter()
365                    .map(|c: &SpaceUpdateChannelInput<'_>| {
366                        let mut obj = serde_json::json!({ "id": c.id });
367                        if let Some(n) = c.name {
368                            obj["name"] = n.into();
369                        }
370                        if let Some(entry) = c
371                            .topic
372                            .map_entry()
373                            .map_err(jmap_base_client::ClientError::Parse)?
374                        {
375                            obj["topic"] = entry;
376                        }
377                        if let Some(entry) = c
378                            .category_id
379                            .map_entry()
380                            .map_err(jmap_base_client::ClientError::Parse)?
381                        {
382                            obj["categoryId"] = entry;
383                        }
384                        if let Some(p) = c.position {
385                            obj["position"] = p.into();
386                        }
387                        if let Some(s) = c.slow_mode_seconds {
388                            obj["slowModeSeconds"] = s.into();
389                        }
390                        if let Some(po) = c.permission_overrides {
391                            obj["permissionOverrides"] = serde_json::to_value(po)
392                                .map_err(jmap_base_client::ClientError::Parse)?;
393                        }
394                        Ok(obj)
395                    })
396                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
397                patch_map.insert("updateChannels".into(), serde_json::Value::Array(arr));
398            }
399        }
400        if let Some(ar) = patch.add_roles {
401            if !ar.is_empty() {
402                let arr = ar
403                    .iter()
404                    .map(|r: &SpaceAddRoleInput<'_>| {
405                        if r.name.is_empty() {
406                            return Err(jmap_base_client::ClientError::InvalidArgument(
407                                "space_update: role name may not be empty".into(),
408                            ));
409                        }
410                        let mut obj = serde_json::json!({
411                            "name": r.name,
412                            "permissions": r.permissions,
413                            "position": r.position,
414                        });
415                        if let Some(c) = r.color {
416                            obj["color"] = c.into();
417                        }
418                        Ok(obj)
419                    })
420                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
421                patch_map.insert("addRoles".into(), serde_json::Value::Array(arr));
422            }
423        }
424        if let Some(rr) = patch.remove_roles {
425            if !rr.is_empty() {
426                patch_map.insert(
427                    "removeRoles".into(),
428                    serde_json::to_value(rr).expect("Id slice Serialize is infallible"),
429                );
430            }
431        }
432        if let Some(ur) = patch.update_roles {
433            if !ur.is_empty() {
434                let arr = ur
435                    .iter()
436                    .map(|r: &SpaceUpdateRoleInput<'_>| {
437                        let mut obj = serde_json::json!({ "id": r.id });
438                        if let Some(n) = r.name {
439                            obj["name"] = n.into();
440                        }
441                        if let Some(entry) = r
442                            .color
443                            .map_entry()
444                            .map_err(jmap_base_client::ClientError::Parse)?
445                        {
446                            obj["color"] = entry;
447                        }
448                        if let Some(perms) = r.permissions {
449                            obj["permissions"] = serde_json::Value::Array(
450                                perms.iter().copied().map(serde_json::Value::from).collect(),
451                            );
452                        }
453                        if let Some(p) = r.position {
454                            obj["position"] = p.into();
455                        }
456                        Ok(obj)
457                    })
458                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
459                patch_map.insert("updateRoles".into(), serde_json::Value::Array(arr));
460            }
461        }
462        if let Some(ac) = patch.add_categories {
463            if !ac.is_empty() {
464                let arr = ac
465                    .iter()
466                    .map(|c: &SpaceAddCategoryInput<'_>| {
467                        if c.name.is_empty() {
468                            return Err(jmap_base_client::ClientError::InvalidArgument(
469                                "space_update: category name may not be empty".into(),
470                            ));
471                        }
472                        let mut obj = serde_json::json!({ "name": c.name });
473                        if let Some(p) = c.position {
474                            obj["position"] = p.into();
475                        }
476                        if let Some(cids) = c.channel_ids {
477                            obj["channelIds"] = serde_json::to_value(cids)
478                                .expect("Id slice Serialize is infallible");
479                        }
480                        Ok(obj)
481                    })
482                    .collect::<Result<Vec<serde_json::Value>, jmap_base_client::ClientError>>()?;
483                patch_map.insert("addCategories".into(), serde_json::Value::Array(arr));
484            }
485        }
486        if let Some(rc) = patch.remove_categories {
487            if !rc.is_empty() {
488                patch_map.insert(
489                    "removeCategories".into(),
490                    serde_json::to_value(rc).expect("Id slice Serialize is infallible"),
491                );
492            }
493        }
494        if let Some(uc) = patch.update_categories {
495            if !uc.is_empty() {
496                let arr = uc
497                    .iter()
498                    .map(|c: &SpaceUpdateCategoryInput<'_>| {
499                        let mut obj = serde_json::json!({ "id": c.id });
500                        if let Some(n) = c.name {
501                            obj["name"] = n.into();
502                        }
503                        if let Some(p) = c.position {
504                            obj["position"] = p.into();
505                        }
506                        if let Some(cids) = c.channel_ids {
507                            obj["channelIds"] = serde_json::to_value(cids)
508                                .expect("Id slice Serialize is infallible");
509                        }
510                        obj
511                    })
512                    .collect::<Vec<serde_json::Value>>();
513                patch_map.insert("updateCategories".into(), serde_json::Value::Array(arr));
514            }
515        }
516
517        // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
518        // serializing. Wire bytes are unchanged because PatchObject is
519        // #[serde(transparent)]; the typed boundary documents the contract.
520        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
521        let args = serde_json::json!({
522            "accountId": account_id,
523            "update": { id.as_ref(): patch_value },
524        });
525        let req = super::build_request("Space/set", args, super::USING_CHAT);
526        let resp = self.call_internal(api_url, &req).await?;
527        jmap_base_client::extract_response(&resp, super::CALL_ID)
528    }
529}