Skip to main content

max / makenotwork

6.2 KB · 203 lines History Blame Raw
1 use super::{CommunityState, PgPool, Utc, Uuid};
2
3 /// Create a new community and return its ID.
4 #[tracing::instrument(skip_all)]
5 pub async fn create_community(
6 pool: &PgPool,
7 name: &str,
8 slug: &str,
9 description: Option<&str>,
10 ) -> Result<Uuid, sqlx::Error> {
11 sqlx::query_scalar!(
12 "INSERT INTO communities (name, slug, description)
13 VALUES ($1, $2, $3)
14 RETURNING id",
15 name,
16 slug,
17 description,
18 )
19 .fetch_one(pool)
20 .await
21 }
22
23 /// Update a community's name and description.
24 #[tracing::instrument(skip_all)]
25 pub async fn update_community<'e, E: sqlx::PgExecutor<'e>>(
26 executor: E,
27 community_id: Uuid,
28 name: &str,
29 description: Option<&str>,
30 auto_hide_threshold: Option<i32>,
31 ) -> Result<(), sqlx::Error> {
32 sqlx::query!(
33 "UPDATE communities SET name = $2, description = $3, auto_hide_threshold = $4 WHERE id = $1",
34 community_id,
35 name,
36 description,
37 auto_hide_threshold,
38 )
39 .execute(executor)
40 .await?;
41 Ok(())
42 }
43
44 /// Suspend a community.
45 #[tracing::instrument(skip_all)]
46 pub async fn suspend_community<'e, E: sqlx::PgExecutor<'e>>(
47 executor: E,
48 community_id: Uuid,
49 reason: Option<&str>,
50 ) -> Result<(), sqlx::Error> {
51 sqlx::query!(
52 "UPDATE communities SET suspended_at = now(), suspension_reason = $2 WHERE id = $1",
53 community_id,
54 reason,
55 )
56 .execute(executor)
57 .await?;
58 Ok(())
59 }
60
61 /// Unsuspend a community.
62 #[tracing::instrument(skip_all)]
63 pub async fn unsuspend_community<'e, E: sqlx::PgExecutor<'e>>(
64 executor: E,
65 community_id: Uuid,
66 ) -> Result<(), sqlx::Error> {
67 sqlx::query!(
68 "UPDATE communities SET suspended_at = NULL, suspension_reason = NULL WHERE id = $1",
69 community_id,
70 )
71 .execute(executor)
72 .await?;
73 Ok(())
74 }
75
76 /// Set the community moderation state. See [`CommunityState`] for semantics.
77 #[tracing::instrument(skip_all)]
78 pub async fn set_community_state<'e, E: sqlx::PgExecutor<'e>>(
79 executor: E,
80 community_id: Uuid,
81 state: CommunityState,
82 ) -> Result<(), sqlx::Error> {
83 sqlx::query!(
84 "UPDATE communities SET state = $2 WHERE id = $1",
85 community_id,
86 state.as_str()
87 )
88 .execute(executor)
89 .await?;
90 Ok(())
91 }
92
93 /// Result of a clean-slate operation.
94 pub struct CleanSlateResult {
95 /// Number of threads deleted (excluding the system reset thread that's
96 /// then inserted). Useful for the success toast.
97 pub deleted_thread_count: i64,
98 /// ID of the system "Community reset" thread created in the first
99 /// category. `None` if the community has no categories, clean-slate
100 /// still deletes threads but has nowhere to post the notice.
101 pub system_thread_id: Option<Uuid>,
102 }
103
104 /// Clean-slate a community: delete all threads (and the posts / footnotes /
105 /// endorsements / flags / read-positions that cascade from them) while
106 /// preserving the community row, categories, memberships, bans, mutes, and
107 /// tags. Posts a system thread "Community reset by &lt;actor&gt; on &lt;date&gt;"
108 /// in the first category by `sort_order`.
109 ///
110 /// Authorization is the caller's responsibility (see `routes/admin.rs`); this
111 /// mutation only enforces atomicity.
112 #[tracing::instrument(skip_all)]
113 pub async fn clean_slate_community(
114 conn: &mut sqlx::PgConnection,
115 community_id: Uuid,
116 actor_id: Uuid,
117 actor_display: &str,
118 ) -> Result<CleanSlateResult, sqlx::Error> {
119 // Delete every thread whose category belongs to this community. Cascades
120 // reap posts, footnotes, endorsements, flags, read-positions, link
121 // previews, mentions, and tag joins.
122 let deleted = sqlx::query_scalar!(
123 r#"WITH d AS (
124 DELETE FROM threads
125 WHERE category_id IN (SELECT id FROM categories WHERE community_id = $1)
126 RETURNING 1
127 )
128 SELECT COUNT(*) AS "count!" FROM d"#,
129 community_id,
130 )
131 .fetch_one(&mut *conn)
132 .await?;
133
134 // Pick the first category by sort_order to host the reset notice. None
135 // means the community has no categories, nothing to post into.
136 let first_category = sqlx::query_scalar!(
137 "SELECT id FROM categories
138 WHERE community_id = $1
139 ORDER BY sort_order
140 LIMIT 1",
141 community_id,
142 )
143 .fetch_optional(&mut *conn)
144 .await?;
145
146 let system_thread_id = if let Some(cat_id) = first_category {
147 let now = Utc::now();
148 let date = now.format("%Y-%m-%d").to_string();
149 let title = format!("Community reset by {actor_display} on {date}");
150 let body_md = format!(
151 "This community was reset by **{actor_display}** on {date}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.",
152 );
153 let body_html = format!(
154 "<p>This community was reset by <strong>{}</strong> on {}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.</p>",
155 html_escape(actor_display),
156 date,
157 );
158
159 let thread_id = sqlx::query_scalar!(
160 "INSERT INTO threads (category_id, author_id, title, pinned, locked)
161 VALUES ($1, $2, $3, TRUE, TRUE)
162 RETURNING id",
163 cat_id,
164 actor_id,
165 title,
166 )
167 .fetch_one(&mut *conn)
168 .await?;
169
170 sqlx::query!(
171 "INSERT INTO posts (thread_id, author_id, body_markdown, body_html)
172 VALUES ($1, $2, $3, $4)",
173 thread_id,
174 actor_id,
175 body_md,
176 body_html,
177 )
178 .execute(&mut *conn)
179 .await?;
180
181 Some(thread_id)
182 } else {
183 None
184 };
185
186 Ok(CleanSlateResult {
187 deleted_thread_count: deleted,
188 system_thread_id,
189 })
190 }
191
192 /// Minimal HTML-escape for actor display names embedded in the reset notice.
193 /// We render the notice as a literal HTML string (skipping the markdown
194 /// pipeline) so we don't have to thread renderer config into the mutation.
195 fn html_escape(input: &str) -> String {
196 input
197 .replace('&', "&amp;")
198 .replace('<', "&lt;")
199 .replace('>', "&gt;")
200 .replace('"', "&quot;")
201 .replace('\'', "&#39;")
202 }
203