Skip to main content

max / makenotwork

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