Skip to main content

dsc/api/
users.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use anyhow::{Context, Result, anyhow};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// One row from /admin/users/list/<type>.json.
8#[derive(Debug, Deserialize, Serialize, Clone)]
9pub struct UserSummary {
10    pub id: u64,
11    pub username: String,
12    #[serde(default)]
13    pub name: Option<String>,
14    #[serde(default)]
15    pub email: Option<String>,
16    #[serde(default)]
17    pub trust_level: Option<u64>,
18    #[serde(default)]
19    pub admin: Option<bool>,
20    #[serde(default)]
21    pub moderator: Option<bool>,
22    #[serde(default)]
23    pub suspended: Option<bool>,
24    #[serde(default)]
25    pub silenced: Option<bool>,
26    #[serde(default)]
27    pub last_seen_at: Option<String>,
28    #[serde(default)]
29    pub created_at: Option<String>,
30}
31
32/// Distilled /users/<username>.json payload.
33#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct UserDetail {
35    pub id: u64,
36    pub username: String,
37    #[serde(default)]
38    pub name: Option<String>,
39    #[serde(default)]
40    pub email: Option<String>,
41    #[serde(default)]
42    pub trust_level: Option<u64>,
43    #[serde(default)]
44    pub admin: Option<bool>,
45    #[serde(default)]
46    pub moderator: Option<bool>,
47    #[serde(default)]
48    pub suspended_till: Option<String>,
49    #[serde(default)]
50    pub silenced_till: Option<String>,
51    #[serde(default)]
52    pub last_seen_at: Option<String>,
53    #[serde(default)]
54    pub created_at: Option<String>,
55    #[serde(default)]
56    pub post_count: Option<u64>,
57    #[serde(default)]
58    pub groups: Vec<Value>,
59}
60
61impl DiscourseClient {
62    /// List users via the admin users endpoint.
63    ///
64    /// `listing` is one of: `active` (default), `new`, `staff`, `suspended`,
65    /// `silenced`, `staged`. Discourse paginates 100 per page.
66    pub fn admin_list_users(&self, listing: &str, page: u32) -> Result<Vec<UserSummary>> {
67        let path = format!(
68            "/admin/users/list/{}.json?show_emails=true&page={}",
69            listing, page
70        );
71        let response = self.get(&path)?;
72        let status = response.status();
73        let text = response.text().context("reading user list response")?;
74        if !status.is_success() {
75            return Err(http_error("admin user list request", status, &text));
76        }
77        let users: Vec<UserSummary> =
78            serde_json::from_str(&text).context("parsing user list response")?;
79        Ok(users)
80    }
81
82    /// Look up a user by username (public endpoint).
83    pub fn fetch_user_detail(&self, username: &str) -> Result<UserDetail> {
84        let path = format!("/u/{}.json", username);
85        let response = self.get(&path)?;
86        let status = response.status();
87        let text = response.text().context("reading user detail response")?;
88        if !status.is_success() {
89            return Err(http_error("user detail request", status, &text));
90        }
91        let value: Value =
92            serde_json::from_str(&text).context("parsing user detail response")?;
93        let user = value
94            .get("user")
95            .ok_or_else(|| anyhow!("user detail response missing `user` field"))?;
96        let detail: UserDetail =
97            serde_json::from_value(user.clone()).context("deserialising user detail")?;
98        Ok(detail)
99    }
100
101    /// Suspend a user by ID. `until` is an ISO-8601 timestamp (or any string
102    /// Discourse accepts, like "forever"); `reason` is mandatory from the UI
103    /// but Discourse accepts empty via the API.
104    pub fn suspend_user(&self, user_id: u64, until: &str, reason: &str) -> Result<()> {
105        let path = format!("/admin/users/{}/suspend.json", user_id);
106        let payload = [
107            ("suspend_until", until),
108            ("reason", reason),
109        ];
110        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
111        let status = response.status();
112        if !status.is_success() {
113            let text = response
114                .text()
115                .unwrap_or_else(|_| "<failed to read response body>".to_string());
116            return Err(http_error("suspend user request", status, &text));
117        }
118        Ok(())
119    }
120
121    /// Unsuspend a user by ID.
122    pub fn unsuspend_user(&self, user_id: u64) -> Result<()> {
123        let path = format!("/admin/users/{}/unsuspend.json", user_id);
124        let response = self.send_retrying(|| Ok(self.put(&path)?))?;
125        let status = response.status();
126        if !status.is_success() {
127            let text = response
128                .text()
129                .unwrap_or_else(|_| "<failed to read response body>".to_string());
130            return Err(http_error("unsuspend user request", status, &text));
131        }
132        Ok(())
133    }
134}