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_json::Value;
9use std::collections::HashSet;
10
11impl DiscourseClient {
12    /// Fetch all groups.
13    pub fn fetch_groups(&self) -> Result<Vec<GroupSummary>> {
14        if let Some(groups) = self.fetch_groups_admin()? {
15            return Ok(groups);
16        }
17        self.fetch_groups_paginated("/groups.json")
18    }
19
20    /// Fetch group details by ID (fallbacks to name lookup if needed).
21    pub fn fetch_group_detail(
22        &self,
23        group_id: u64,
24        group_name: Option<&str>,
25    ) -> Result<GroupDetail> {
26        let id_path = format!("/groups/{}.json", group_id);
27        if let Some(detail) = self.fetch_group_detail_by_path(&id_path)? {
28            return Ok(detail);
29        }
30        if let Some(name) = group_name {
31            let name_path = format!("/groups/{}.json", name);
32            if let Some(detail) = self.fetch_group_detail_by_path(&name_path)? {
33                return Ok(detail);
34            }
35        }
36        Err(anyhow!("group not found: {}", group_id))
37    }
38
39    pub fn fetch_group_members(
40        &self,
41        group_id: u64,
42        group_name: Option<&str>,
43    ) -> Result<Vec<GroupMember>> {
44        let id_path = format!("/groups/{}/members.json", group_id);
45        if let Some(members) = self.fetch_group_members_by_path(&id_path)? {
46            return Ok(members);
47        }
48        if let Some(name) = group_name {
49            let name_path = format!("/groups/{}/members.json", name);
50            if let Some(members) = self.fetch_group_members_by_path(&name_path)? {
51                return Ok(members);
52            }
53        }
54        Err(anyhow!("group not found: {}", group_id))
55    }
56
57    /// Create a group with detailed settings copied from a source group.
58    pub fn create_group(&self, group: &GroupDetail) -> Result<u64> {
59        let mut payload: Vec<(String, String)> = Vec::new();
60        payload.push(("group[name]".to_string(), group.name.clone()));
61        if let Some(full_name) = group.full_name.clone() {
62            payload.push(("group[full_name]".to_string(), full_name));
63        }
64        push_opt(&mut payload, "group[title]", group.title.as_deref());
65        push_opt(
66            &mut payload,
67            "group[grant_trust_level]",
68            group
69                .grant_trust_level
70                .as_ref()
71                .map(|v| v.to_string())
72                .as_deref(),
73        );
74        push_opt(
75            &mut payload,
76            "group[visibility_level]",
77            group
78                .visibility_level
79                .as_ref()
80                .map(|v| v.to_string())
81                .as_deref(),
82        );
83        push_opt(
84            &mut payload,
85            "group[mentionable_level]",
86            group
87                .mentionable_level
88                .as_ref()
89                .map(|v| v.to_string())
90                .as_deref(),
91        );
92        push_opt(
93            &mut payload,
94            "group[messageable_level]",
95            group
96                .messageable_level
97                .as_ref()
98                .map(|v| v.to_string())
99                .as_deref(),
100        );
101        push_opt(
102            &mut payload,
103            "group[default_notification_level]",
104            group
105                .default_notification_level
106                .as_ref()
107                .map(|v| v.to_string())
108                .as_deref(),
109        );
110        push_opt(
111            &mut payload,
112            "group[members_visibility_level]",
113            group
114                .members_visibility_level
115                .as_ref()
116                .map(|v| v.to_string())
117                .as_deref(),
118        );
119        push_opt(
120            &mut payload,
121            "group[primary_group]",
122            group
123                .primary_group
124                .as_ref()
125                .map(|v| v.to_string())
126                .as_deref(),
127        );
128        push_opt(
129            &mut payload,
130            "group[public_admission]",
131            group
132                .public_admission
133                .as_ref()
134                .map(|v| v.to_string())
135                .as_deref(),
136        );
137        push_opt(
138            &mut payload,
139            "group[public_exit]",
140            group.public_exit.as_ref().map(|v| v.to_string()).as_deref(),
141        );
142        push_opt(
143            &mut payload,
144            "group[allow_membership_requests]",
145            group
146                .allow_membership_requests
147                .as_ref()
148                .map(|v| v.to_string())
149                .as_deref(),
150        );
151        push_opt(
152            &mut payload,
153            "group[automatic_membership_email_domains]",
154            group.automatic_membership_email_domains.as_deref(),
155        );
156        push_opt(
157            &mut payload,
158            "group[automatic_membership_retroactive]",
159            group
160                .automatic_membership_retroactive
161                .as_ref()
162                .map(|v| v.to_string())
163                .as_deref(),
164        );
165        push_opt(
166            &mut payload,
167            "group[membership_request_template]",
168            group.membership_request_template.as_deref(),
169        );
170        push_opt(
171            &mut payload,
172            "group[flair_icon]",
173            group.flair_icon.as_deref(),
174        );
175        push_opt(
176            &mut payload,
177            "group[flair_upload_id]",
178            group
179                .flair_upload_id
180                .as_ref()
181                .map(|v| v.to_string())
182                .as_deref(),
183        );
184        push_opt(
185            &mut payload,
186            "group[flair_color]",
187            group.flair_color.as_deref(),
188        );
189        push_opt(
190            &mut payload,
191            "group[flair_background_color]",
192            group.flair_background_color.as_deref(),
193        );
194        push_opt(&mut payload, "group[bio_raw]", group.bio_raw.as_deref());
195        let response = self.send_retrying(|| Ok(self.post("/admin/groups")?.form(&payload)))?;
196        let status = response.status();
197        let text = response.text().context("reading group response body")?;
198        if !status.is_success() {
199            return Err(http_error("create group request", status, &text));
200        }
201        let value: Value = serde_json::from_str(&text).context("parsing group response json")?;
202        let id = value
203            .get("group")
204            .and_then(|group| group.get("id"))
205            .and_then(|id| id.as_u64())
206            .or_else(|| {
207                value
208                    .get("basic_group")
209                    .and_then(|g| g.get("id"))
210                    .and_then(|id| id.as_u64())
211            })
212            .or_else(|| value.get("id").and_then(|id| id.as_u64()))
213            .ok_or_else(|| anyhow!("missing group id in response: {}", text))?;
214        Ok(id)
215    }
216
217    fn fetch_group_detail_by_path(&self, path: &str) -> Result<Option<GroupDetail>> {
218        let response = self.get(path)?;
219        let status = response.status();
220        let text = response.text().context("reading group detail body")?;
221        if !status.is_success() {
222            if status == StatusCode::NOT_FOUND {
223                return Ok(None);
224            }
225            return Err(http_error("group detail request", status, &text));
226        }
227        let body: GroupDetailResponse =
228            serde_json::from_str(&text).context("parsing group detail json")?;
229        Ok(Some(body.group))
230    }
231
232    fn fetch_group_members_by_path(&self, path: &str) -> Result<Option<Vec<GroupMember>>> {
233        let response = self.get(path)?;
234        let status = response.status();
235        let text = response.text().context("reading group members body")?;
236        if !status.is_success() {
237            if status == StatusCode::NOT_FOUND {
238                return Ok(None);
239            }
240            return Err(http_error("group members request", status, &text));
241        }
242        let body: GroupMembersResponse =
243            serde_json::from_str(&text).context("parsing group members json")?;
244        Ok(Some(body.members))
245    }
246
247    fn fetch_groups_admin(&self) -> Result<Option<Vec<GroupSummary>>> {
248        let response = self.get("/admin/groups.json")?;
249        let status = response.status();
250        let text = response.text().context("reading groups response body")?;
251        if status.is_success() {
252            if text.trim().is_empty() {
253                return Ok(None);
254            }
255            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
256            return Ok(Some(extract_groups_from_value(&value)?));
257        }
258        if status == StatusCode::NOT_FOUND {
259            return Ok(None);
260        }
261        Err(http_error("groups request", status, &text))
262    }
263
264    fn fetch_groups_paginated(&self, path: &str) -> Result<Vec<GroupSummary>> {
265        let mut out = Vec::new();
266        let mut seen = HashSet::new();
267        let mut next_path = Some(path.to_string());
268
269        while let Some(path) = next_path.take() {
270            let path = self.normalize_groups_path(&path);
271            if !seen.insert(path.clone()) {
272                return Err(anyhow!("groups request loop detected at {}", path));
273            }
274            let response = self.get(&path)?;
275            let status = response.status();
276            let text = response.text().context("reading groups response body")?;
277            if !status.is_success() {
278                return Err(http_error("groups request", status, &text));
279            }
280            if text.trim().is_empty() {
281                return Err(anyhow!(
282                    "groups request failed with {} (empty response)",
283                    status
284                ));
285            }
286            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
287            let page_groups = extract_groups_from_value(&value)?;
288            if page_groups.is_empty() {
289                break;
290            }
291            out.extend(page_groups);
292            next_path = extract_next_groups_path(&value);
293        }
294
295        Ok(out)
296    }
297
298    fn normalize_groups_path(&self, path: &str) -> String {
299        let mut path = path.to_string();
300        if let Some(stripped) = path.strip_prefix(self.baseurl()) {
301            path = stripped.to_string();
302        }
303        if !path.starts_with('/') {
304            path = format!("/{}", path);
305        }
306        if path.contains(".json") {
307            return path;
308        }
309        if let Some((base, query)) = path.split_once('?') {
310            format!("{}.json?{}", base, query)
311        } else {
312            format!("{}.json", path)
313        }
314    }
315}
316
317fn push_opt(payload: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
318    if let Some(value) = value {
319        payload.push((key.to_string(), value.to_string()));
320    }
321}
322
323fn extract_groups_from_value(value: &Value) -> Result<Vec<GroupSummary>> {
324    let groups = if let Some(arr) = value.as_array() {
325        arr
326    } else {
327        value
328            .get("groups")
329            .and_then(|v| v.as_array())
330            .ok_or_else(|| anyhow!("groups response missing groups array"))?
331    };
332    let mut out = Vec::with_capacity(groups.len());
333    for group in groups {
334        let parsed: GroupSummary =
335            serde_json::from_value(group.clone()).context("parsing group summary")?;
336        out.push(parsed);
337    }
338    Ok(out)
339}
340
341fn extract_next_groups_path(value: &Value) -> Option<String> {
342    let direct = value
343        .get("load_more_groups")
344        .and_then(|v| v.as_str())
345        .map(|s| s.to_string());
346    if direct
347        .as_deref()
348        .map(|s| !s.trim().is_empty())
349        .unwrap_or(false)
350    {
351        return direct;
352    }
353    value
354        .get("extras")
355        .and_then(|extras| extras.get("load_more_groups"))
356        .and_then(|v| v.as_str())
357        .map(|s| s.to_string())
358        .filter(|s| !s.trim().is_empty())
359}