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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
14pub struct AddMembersOutcome {
15 pub added_usernames: Vec<String>,
17 pub errors: Vec<String>,
19}
20
21impl DiscourseClient {
22 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 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 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 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 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 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 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}