Skip to main content

dsc/api/
groups.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::{
4    GroupDetail, GroupDetailResponse, GroupMember, GroupMembersResponse, GroupSummary,
5};
6use anyhow::{Context, Result, anyhow};
7use reqwest::StatusCode;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashSet;
11
12/// Result of a bulk add-members call.
13#[derive(Debug, Default, Clone, Serialize, Deserialize)]
14pub struct AddMembersOutcome {
15    /// Usernames Discourse reported as added by the call.
16    pub added_usernames: Vec<String>,
17    /// Error strings Discourse returned (unknown emails, etc.).
18    pub errors: Vec<String>,
19}
20
21impl DiscourseClient {
22    /// Fetch all groups.
23    pub fn fetch_groups(&self) -> Result<Vec<GroupSummary>> {
24        if let Some(groups) = self.fetch_groups_admin()? {
25            return Ok(groups);
26        }
27        self.fetch_groups_paginated("/groups.json")
28    }
29
30    /// Fetch group details by ID (fallbacks to name lookup if needed).
31    pub fn fetch_group_detail(
32        &self,
33        group_id: u64,
34        group_name: Option<&str>,
35    ) -> Result<GroupDetail> {
36        let id_path = format!("/groups/{}.json", group_id);
37        if let Some(detail) = self.fetch_group_detail_by_path(&id_path)? {
38            return Ok(detail);
39        }
40        if let Some(name) = group_name {
41            let name_path = format!("/groups/{}.json", name);
42            if let Some(detail) = self.fetch_group_detail_by_path(&name_path)? {
43                return Ok(detail);
44            }
45        }
46        Err(anyhow!("group not found: {}", group_id))
47    }
48
49    pub fn fetch_group_members(
50        &self,
51        group_id: u64,
52        group_name: Option<&str>,
53    ) -> Result<Vec<GroupMember>> {
54        let id_path = format!("/groups/{}/members.json", group_id);
55        if let Some(members) = self.fetch_group_members_by_path(&id_path)? {
56            return Ok(members);
57        }
58        if let Some(name) = group_name {
59            let name_path = format!("/groups/{}/members.json", name);
60            if let Some(members) = self.fetch_group_members_by_path(&name_path)? {
61                return Ok(members);
62            }
63        }
64        Err(anyhow!("group not found: {}", group_id))
65    }
66
67    /// Create a group with detailed settings copied from a source group.
68    pub fn create_group(&self, group: &GroupDetail) -> Result<u64> {
69        let mut payload: Vec<(String, String)> = Vec::new();
70        payload.push(("group[name]".to_string(), group.name.clone()));
71        if let Some(full_name) = group.full_name.clone() {
72            payload.push(("group[full_name]".to_string(), full_name));
73        }
74        push_opt(&mut payload, "group[title]", group.title.as_deref());
75        push_opt(
76            &mut payload,
77            "group[grant_trust_level]",
78            group
79                .grant_trust_level
80                .as_ref()
81                .map(|v| v.to_string())
82                .as_deref(),
83        );
84        push_opt(
85            &mut payload,
86            "group[visibility_level]",
87            group
88                .visibility_level
89                .as_ref()
90                .map(|v| v.to_string())
91                .as_deref(),
92        );
93        push_opt(
94            &mut payload,
95            "group[mentionable_level]",
96            group
97                .mentionable_level
98                .as_ref()
99                .map(|v| v.to_string())
100                .as_deref(),
101        );
102        push_opt(
103            &mut payload,
104            "group[messageable_level]",
105            group
106                .messageable_level
107                .as_ref()
108                .map(|v| v.to_string())
109                .as_deref(),
110        );
111        push_opt(
112            &mut payload,
113            "group[default_notification_level]",
114            group
115                .default_notification_level
116                .as_ref()
117                .map(|v| v.to_string())
118                .as_deref(),
119        );
120        push_opt(
121            &mut payload,
122            "group[members_visibility_level]",
123            group
124                .members_visibility_level
125                .as_ref()
126                .map(|v| v.to_string())
127                .as_deref(),
128        );
129        push_opt(
130            &mut payload,
131            "group[primary_group]",
132            group
133                .primary_group
134                .as_ref()
135                .map(|v| v.to_string())
136                .as_deref(),
137        );
138        push_opt(
139            &mut payload,
140            "group[public_admission]",
141            group
142                .public_admission
143                .as_ref()
144                .map(|v| v.to_string())
145                .as_deref(),
146        );
147        push_opt(
148            &mut payload,
149            "group[public_exit]",
150            group.public_exit.as_ref().map(|v| v.to_string()).as_deref(),
151        );
152        push_opt(
153            &mut payload,
154            "group[allow_membership_requests]",
155            group
156                .allow_membership_requests
157                .as_ref()
158                .map(|v| v.to_string())
159                .as_deref(),
160        );
161        push_opt(
162            &mut payload,
163            "group[automatic_membership_email_domains]",
164            group.automatic_membership_email_domains.as_deref(),
165        );
166        push_opt(
167            &mut payload,
168            "group[automatic_membership_retroactive]",
169            group
170                .automatic_membership_retroactive
171                .as_ref()
172                .map(|v| v.to_string())
173                .as_deref(),
174        );
175        push_opt(
176            &mut payload,
177            "group[membership_request_template]",
178            group.membership_request_template.as_deref(),
179        );
180        push_opt(
181            &mut payload,
182            "group[flair_icon]",
183            group.flair_icon.as_deref(),
184        );
185        push_opt(
186            &mut payload,
187            "group[flair_upload_id]",
188            group
189                .flair_upload_id
190                .as_ref()
191                .map(|v| v.to_string())
192                .as_deref(),
193        );
194        push_opt(
195            &mut payload,
196            "group[flair_color]",
197            group.flair_color.as_deref(),
198        );
199        push_opt(
200            &mut payload,
201            "group[flair_background_color]",
202            group.flair_background_color.as_deref(),
203        );
204        push_opt(&mut payload, "group[bio_raw]", group.bio_raw.as_deref());
205        let response = self.send_retrying(|| Ok(self.post("/admin/groups")?.form(&payload)))?;
206        let status = response.status();
207        let text = response.text().context("reading group response body")?;
208        if !status.is_success() {
209            return Err(http_error("create group request", status, &text));
210        }
211        let value: Value = serde_json::from_str(&text).context("parsing group response json")?;
212        let id = value
213            .get("group")
214            .and_then(|group| group.get("id"))
215            .and_then(|id| id.as_u64())
216            .or_else(|| {
217                value
218                    .get("basic_group")
219                    .and_then(|g| g.get("id"))
220                    .and_then(|id| id.as_u64())
221            })
222            .or_else(|| value.get("id").and_then(|id| id.as_u64()))
223            .ok_or_else(|| anyhow!("missing group id in response: {}", text))?;
224        Ok(id)
225    }
226
227    /// Add members to a group by username (PUT /groups/:id/members.json).
228    pub fn add_group_members_by_username(
229        &self,
230        group_id: u64,
231        usernames: &[String],
232        notify_users: bool,
233    ) -> Result<AddMembersOutcome> {
234        if usernames.is_empty() {
235            return Ok(AddMembersOutcome::default());
236        }
237        let path = format!("/groups/{}/members.json", group_id);
238        let joined = usernames.join(",");
239        let notify = if notify_users { "true" } else { "false" };
240        let payload = [("usernames", joined.as_str()), ("notify_users", notify)];
241        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
242        let status = response.status();
243        let text = response.text().context("reading add-members response")?;
244        if !status.is_success() {
245            return Err(http_error("add group members request", status, &text));
246        }
247        parse_add_members_outcome(&text)
248    }
249
250    /// Remove members from a group by username (DELETE /groups/:id/members.json).
251    pub fn remove_group_members_by_username(
252        &self,
253        group_id: u64,
254        usernames: &[String],
255    ) -> Result<()> {
256        if usernames.is_empty() {
257            return Ok(());
258        }
259        let path = format!(
260            "/groups/{}/members.json?usernames={}",
261            group_id,
262            usernames.join(",")
263        );
264        let response = self.send_retrying(|| Ok(self.delete_builder(&path)?))?;
265        let status = response.status();
266        if !status.is_success() {
267            let text = response
268                .text()
269                .unwrap_or_else(|_| "<failed to read response body>".to_string());
270            return Err(http_error("remove group members request", status, &text));
271        }
272        Ok(())
273    }
274
275    /// Return the list of groups a user belongs to, by username.
276    pub fn fetch_user_groups(&self, username: &str) -> Result<Vec<GroupSummary>> {
277        let path = format!("/u/{}.json", username);
278        let response = self.get(&path)?;
279        let status = response.status();
280        let text = response.text().context("reading user response body")?;
281        if !status.is_success() {
282            return Err(http_error("user request", status, &text));
283        }
284        let value: Value = serde_json::from_str(&text).context("parsing user response json")?;
285        let groups = value
286            .get("user")
287            .and_then(|u| u.get("groups"))
288            .and_then(|g| g.as_array())
289            .map(|arr| {
290                arr.iter()
291                    .filter_map(|v| serde_json::from_value::<GroupSummary>(v.clone()).ok())
292                    .collect()
293            })
294            .unwrap_or_default();
295        Ok(groups)
296    }
297
298    /// Add members to a group by email (PUT /groups/:id/members.json).
299    ///
300    /// Returns a tuple of (added_usernames, not_found_emails) parsed loosely
301    /// from the response; both lists may be empty on success if the response
302    /// doesn't surface them.
303    pub fn add_group_members_by_email(
304        &self,
305        group_id: u64,
306        emails: &[String],
307        notify_users: bool,
308    ) -> Result<AddMembersOutcome> {
309        if emails.is_empty() {
310            return Ok(AddMembersOutcome::default());
311        }
312        let path = format!("/groups/{}/members.json", group_id);
313        let joined = emails.join(",");
314        let notify = if notify_users { "true" } else { "false" };
315        let payload = [("emails", joined.as_str()), ("notify_users", notify)];
316        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
317        let status = response.status();
318        let text = response.text().context("reading add-members response")?;
319        if !status.is_success() {
320            return Err(http_error("add group members request", status, &text));
321        }
322        parse_add_members_outcome(&text)
323    }
324
325    fn fetch_group_detail_by_path(&self, path: &str) -> Result<Option<GroupDetail>> {
326        let response = self.get(path)?;
327        let status = response.status();
328        let text = response.text().context("reading group detail body")?;
329        if !status.is_success() {
330            if status == StatusCode::NOT_FOUND {
331                return Ok(None);
332            }
333            return Err(http_error("group detail request", status, &text));
334        }
335        let body: GroupDetailResponse =
336            serde_json::from_str(&text).context("parsing group detail json")?;
337        Ok(Some(body.group))
338    }
339
340    fn fetch_group_members_by_path(&self, path: &str) -> Result<Option<Vec<GroupMember>>> {
341        let response = self.get(path)?;
342        let status = response.status();
343        let text = response.text().context("reading group members body")?;
344        if !status.is_success() {
345            if status == StatusCode::NOT_FOUND {
346                return Ok(None);
347            }
348            return Err(http_error("group members request", status, &text));
349        }
350        let body: GroupMembersResponse =
351            serde_json::from_str(&text).context("parsing group members json")?;
352        Ok(Some(body.members))
353    }
354
355    fn fetch_groups_admin(&self) -> Result<Option<Vec<GroupSummary>>> {
356        let response = self.get("/admin/groups.json")?;
357        let status = response.status();
358        let text = response.text().context("reading groups response body")?;
359        if status.is_success() {
360            if text.trim().is_empty() {
361                return Ok(None);
362            }
363            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
364            return Ok(Some(extract_groups_from_value(&value)?));
365        }
366        if status == StatusCode::NOT_FOUND {
367            return Ok(None);
368        }
369        Err(http_error("groups request", status, &text))
370    }
371
372    fn fetch_groups_paginated(&self, path: &str) -> Result<Vec<GroupSummary>> {
373        let mut out = Vec::new();
374        let mut seen = HashSet::new();
375        let mut next_path = Some(path.to_string());
376
377        while let Some(path) = next_path.take() {
378            let path = self.normalize_groups_path(&path);
379            if !seen.insert(path.clone()) {
380                return Err(anyhow!("groups request loop detected at {}", path));
381            }
382            let response = self.get(&path)?;
383            let status = response.status();
384            let text = response.text().context("reading groups response body")?;
385            if !status.is_success() {
386                return Err(http_error("groups request", status, &text));
387            }
388            if text.trim().is_empty() {
389                return Err(anyhow!(
390                    "groups request failed with {} (empty response)",
391                    status
392                ));
393            }
394            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
395            let page_groups = extract_groups_from_value(&value)?;
396            if page_groups.is_empty() {
397                break;
398            }
399            out.extend(page_groups);
400            next_path = extract_next_groups_path(&value);
401        }
402
403        Ok(out)
404    }
405
406    fn normalize_groups_path(&self, path: &str) -> String {
407        let mut path = path.to_string();
408        if let Some(stripped) = path.strip_prefix(self.baseurl()) {
409            path = stripped.to_string();
410        }
411        if !path.starts_with('/') {
412            path = format!("/{}", path);
413        }
414        if path.contains(".json") {
415            return path;
416        }
417        if let Some((base, query)) = path.split_once('?') {
418            format!("{}.json?{}", base, query)
419        } else {
420            format!("{}.json", path)
421        }
422    }
423}
424
425fn push_opt(payload: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
426    if let Some(value) = value {
427        payload.push((key.to_string(), value.to_string()));
428    }
429}
430
431fn extract_groups_from_value(value: &Value) -> Result<Vec<GroupSummary>> {
432    let groups = if let Some(arr) = value.as_array() {
433        arr
434    } else {
435        value
436            .get("groups")
437            .and_then(|v| v.as_array())
438            .ok_or_else(|| anyhow!("groups response missing groups array"))?
439    };
440    let mut out = Vec::with_capacity(groups.len());
441    for group in groups {
442        let parsed: GroupSummary =
443            serde_json::from_value(group.clone()).context("parsing group summary")?;
444        out.push(parsed);
445    }
446    Ok(out)
447}
448
449fn extract_next_groups_path(value: &Value) -> Option<String> {
450    let direct = value
451        .get("load_more_groups")
452        .and_then(|v| v.as_str())
453        .map(|s| s.to_string());
454    if direct
455        .as_deref()
456        .map(|s| !s.trim().is_empty())
457        .unwrap_or(false)
458    {
459        return direct;
460    }
461    value
462        .get("extras")
463        .and_then(|extras| extras.get("load_more_groups"))
464        .and_then(|v| v.as_str())
465        .map(|s| s.to_string())
466        .filter(|s| !s.trim().is_empty())
467}
468
469fn parse_add_members_outcome(body: &str) -> Result<AddMembersOutcome> {
470    let value: Value = serde_json::from_str(body).context("parsing add-members response json")?;
471    let added_usernames = value
472        .get("usernames")
473        .and_then(|v| v.as_array())
474        .map(|arr| {
475            arr.iter()
476                .filter_map(|v| v.as_str().map(|s| s.to_string()))
477                .collect()
478        })
479        .unwrap_or_default();
480    let errors = value
481        .get("errors")
482        .and_then(|v| v.as_array())
483        .map(|arr| {
484            arr.iter()
485                .filter_map(|v| v.as_str().map(|s| s.to_string()))
486                .collect()
487        })
488        .unwrap_or_default();
489    Ok(AddMembersOutcome {
490        added_usernames,
491        errors,
492    })
493}