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
196            .post("/admin/groups")?
197            .form(&payload)
198            .send()
199            .context("creating group")?;
200        let status = response.status();
201        let text = response.text().context("reading group response body")?;
202        if !status.is_success() {
203            return Err(http_error("create group request", status, &text));
204        }
205        let value: Value = serde_json::from_str(&text).context("parsing group response json")?;
206        let id = value
207            .get("group")
208            .and_then(|group| group.get("id"))
209            .and_then(|id| id.as_u64())
210            .or_else(|| {
211                value
212                    .get("basic_group")
213                    .and_then(|g| g.get("id"))
214                    .and_then(|id| id.as_u64())
215            })
216            .or_else(|| value.get("id").and_then(|id| id.as_u64()))
217            .ok_or_else(|| anyhow!("missing group id in response: {}", text))?;
218        Ok(id)
219    }
220
221    fn fetch_group_detail_by_path(&self, path: &str) -> Result<Option<GroupDetail>> {
222        let response = self.get(path)?;
223        let status = response.status();
224        let text = response.text().context("reading group detail body")?;
225        if !status.is_success() {
226            if status == StatusCode::NOT_FOUND {
227                return Ok(None);
228            }
229            return Err(http_error("group detail request", status, &text));
230        }
231        let body: GroupDetailResponse =
232            serde_json::from_str(&text).context("parsing group detail json")?;
233        Ok(Some(body.group))
234    }
235
236    fn fetch_group_members_by_path(&self, path: &str) -> Result<Option<Vec<GroupMember>>> {
237        let response = self.get(path)?;
238        let status = response.status();
239        let text = response.text().context("reading group members body")?;
240        if !status.is_success() {
241            if status == StatusCode::NOT_FOUND {
242                return Ok(None);
243            }
244            return Err(http_error("group members request", status, &text));
245        }
246        let body: GroupMembersResponse =
247            serde_json::from_str(&text).context("parsing group members json")?;
248        Ok(Some(body.members))
249    }
250
251    fn fetch_groups_admin(&self) -> Result<Option<Vec<GroupSummary>>> {
252        let response = self.get("/admin/groups.json")?;
253        let status = response.status();
254        let text = response.text().context("reading groups response body")?;
255        if status.is_success() {
256            if text.trim().is_empty() {
257                return Ok(None);
258            }
259            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
260            return Ok(Some(extract_groups_from_value(&value)?));
261        }
262        if status == StatusCode::NOT_FOUND {
263            return Ok(None);
264        }
265        Err(http_error("groups request", status, &text))
266    }
267
268    fn fetch_groups_paginated(&self, path: &str) -> Result<Vec<GroupSummary>> {
269        let mut out = Vec::new();
270        let mut seen = HashSet::new();
271        let mut next_path = Some(path.to_string());
272
273        while let Some(path) = next_path.take() {
274            let path = self.normalize_groups_path(&path);
275            if !seen.insert(path.clone()) {
276                return Err(anyhow!("groups request loop detected at {}", path));
277            }
278            let response = self.get(&path)?;
279            let status = response.status();
280            let text = response.text().context("reading groups response body")?;
281            if !status.is_success() {
282                return Err(http_error("groups request", status, &text));
283            }
284            if text.trim().is_empty() {
285                return Err(anyhow!(
286                    "groups request failed with {} (empty response)",
287                    status
288                ));
289            }
290            let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
291            let page_groups = extract_groups_from_value(&value)?;
292            if page_groups.is_empty() {
293                break;
294            }
295            out.extend(page_groups);
296            next_path = extract_next_groups_path(&value);
297        }
298
299        Ok(out)
300    }
301
302    fn normalize_groups_path(&self, path: &str) -> String {
303        let mut path = path.to_string();
304        if let Some(stripped) = path.strip_prefix(self.baseurl()) {
305            path = stripped.to_string();
306        }
307        if !path.starts_with('/') {
308            path = format!("/{}", path);
309        }
310        if path.contains(".json") {
311            return path;
312        }
313        if let Some((base, query)) = path.split_once('?') {
314            format!("{}.json?{}", base, query)
315        } else {
316            format!("{}.json", path)
317        }
318    }
319}
320
321fn push_opt(payload: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
322    if let Some(value) = value {
323        payload.push((key.to_string(), value.to_string()));
324    }
325}
326
327fn extract_groups_from_value(value: &Value) -> Result<Vec<GroupSummary>> {
328    let groups = if let Some(arr) = value.as_array() {
329        arr
330    } else {
331        value
332            .get("groups")
333            .and_then(|v| v.as_array())
334            .ok_or_else(|| anyhow!("groups response missing groups array"))?
335    };
336    let mut out = Vec::with_capacity(groups.len());
337    for group in groups {
338        let parsed: GroupSummary =
339            serde_json::from_value(group.clone()).context("parsing group summary")?;
340        out.push(parsed);
341    }
342    Ok(out)
343}
344
345fn extract_next_groups_path(value: &Value) -> Option<String> {
346    let direct = value
347        .get("load_more_groups")
348        .and_then(|v| v.as_str())
349        .map(|s| s.to_string());
350    if direct
351        .as_deref()
352        .map(|s| !s.trim().is_empty())
353        .unwrap_or(false)
354    {
355        return direct;
356    }
357    value
358        .get("extras")
359        .and_then(|extras| extras.get("load_more_groups"))
360        .and_then(|v| v.as_str())
361        .map(|s| s.to_string())
362        .filter(|s| !s.trim().is_empty())
363}