Skip to main content

max / makenotwork

7.3 KB · 236 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 /// Update a community's chat policy and retention window.
47 ///
48 /// Separate from [`update_community`] because the two screens save separately
49 /// and folding them would make each form silently rewrite the other's fields.
50 ///
51 /// Both bounds are validated by the caller against the crate's ceilings before
52 /// they arrive here. Migration 039's CHECKs restate those ceilings and stay a
53 /// backstop: reaching them means a call site skipped its validation, and failing
54 /// at the database is the right outcome for that.
55 #[tracing::instrument(skip_all)]
56 pub async fn update_chat_settings<'e, E: sqlx::PgExecutor<'e>>(
57 executor: E,
58 community_id: Uuid,
59 policy: &str,
60 retention_hours: i32,
61 max_messages: i32,
62 ) -> Result<(), sqlx::Error> {
63 sqlx::query!(
64 "UPDATE communities
65 SET chat_policy = $2, chat_retention_hours = $3, chat_max_messages = $4
66 WHERE id = $1",
67 community_id,
68 policy,
69 retention_hours,
70 max_messages,
71 )
72 .execute(executor)
73 .await?;
74 Ok(())
75 }
76
77 /// Suspend a community.
78 #[tracing::instrument(skip_all)]
79 pub async fn suspend_community<'e, E: sqlx::PgExecutor<'e>>(
80 executor: E,
81 community_id: Uuid,
82 reason: Option<&str>,
83 ) -> Result<(), sqlx::Error> {
84 sqlx::query!(
85 "UPDATE communities SET suspended_at = now(), suspension_reason = $2 WHERE id = $1",
86 community_id,
87 reason,
88 )
89 .execute(executor)
90 .await?;
91 Ok(())
92 }
93
94 /// Unsuspend a community.
95 #[tracing::instrument(skip_all)]
96 pub async fn unsuspend_community<'e, E: sqlx::PgExecutor<'e>>(
97 executor: E,
98 community_id: Uuid,
99 ) -> Result<(), sqlx::Error> {
100 sqlx::query!(
101 "UPDATE communities SET suspended_at = NULL, suspension_reason = NULL WHERE id = $1",
102 community_id,
103 )
104 .execute(executor)
105 .await?;
106 Ok(())
107 }
108
109 /// Set the community moderation state. See [`CommunityState`] for semantics.
110 #[tracing::instrument(skip_all)]
111 pub async fn set_community_state<'e, E: sqlx::PgExecutor<'e>>(
112 executor: E,
113 community_id: Uuid,
114 state: CommunityState,
115 ) -> Result<(), sqlx::Error> {
116 sqlx::query!(
117 "UPDATE communities SET state = $2 WHERE id = $1",
118 community_id,
119 state.as_str()
120 )
121 .execute(executor)
122 .await?;
123 Ok(())
124 }
125
126 /// Result of a clean-slate operation.
127 pub struct CleanSlateResult {
128 /// Number of threads deleted (excluding the system reset thread that's
129 /// then inserted). Useful for the success toast.
130 pub deleted_thread_count: i64,
131 /// ID of the system "Community reset" thread created in the first
132 /// category. `None` if the community has no categories, clean-slate
133 /// still deletes threads but has nowhere to post the notice.
134 pub system_thread_id: Option<Uuid>,
135 }
136
137 /// Clean-slate a community: delete all threads (and the posts / footnotes /
138 /// endorsements / flags / read-positions that cascade from them) while
139 /// preserving the community row, categories, memberships, bans, mutes, and
140 /// tags. Posts a system thread "Community reset by &lt;actor&gt; on &lt;date&gt;"
141 /// in the first category by `sort_order`.
142 ///
143 /// Authorization is the caller's responsibility (see `routes/admin.rs`); this
144 /// mutation only enforces atomicity.
145 #[tracing::instrument(skip_all)]
146 pub async fn clean_slate_community(
147 conn: &mut sqlx::PgConnection,
148 community_id: Uuid,
149 actor_id: Uuid,
150 actor_display: &str,
151 ) -> Result<CleanSlateResult, sqlx::Error> {
152 // Delete every thread whose category belongs to this community. Cascades
153 // reap posts, footnotes, endorsements, flags, read-positions, link
154 // previews, mentions, and tag joins.
155 let deleted = sqlx::query_scalar!(
156 r#"WITH d AS (
157 DELETE FROM threads
158 WHERE category_id IN (SELECT id FROM categories WHERE community_id = $1)
159 RETURNING 1
160 )
161 SELECT COUNT(*) AS "count!" FROM d"#,
162 community_id,
163 )
164 .fetch_one(&mut *conn)
165 .await?;
166
167 // Pick the first category by sort_order to host the reset notice. None
168 // means the community has no categories, nothing to post into.
169 let first_category = sqlx::query_scalar!(
170 "SELECT id FROM categories
171 WHERE community_id = $1
172 ORDER BY sort_order
173 LIMIT 1",
174 community_id,
175 )
176 .fetch_optional(&mut *conn)
177 .await?;
178
179 let system_thread_id = if let Some(cat_id) = first_category {
180 let now = Utc::now();
181 let date = now.format("%Y-%m-%d").to_string();
182 let title = format!("Community reset by {actor_display} on {date}");
183 let body_md = format!(
184 "This community was reset by **{actor_display}** on {date}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.",
185 );
186 let body_html = format!(
187 "<p>This community was reset by <strong>{}</strong> on {}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.</p>",
188 html_escape(actor_display),
189 date,
190 );
191
192 let thread_id = sqlx::query_scalar!(
193 "INSERT INTO threads (category_id, author_id, title, pinned, locked)
194 VALUES ($1, $2, $3, TRUE, TRUE)
195 RETURNING id",
196 cat_id,
197 actor_id,
198 title,
199 )
200 .fetch_one(&mut *conn)
201 .await?;
202
203 sqlx::query!(
204 "INSERT INTO posts (thread_id, author_id, body_markdown, body_html)
205 VALUES ($1, $2, $3, $4)",
206 thread_id,
207 actor_id,
208 body_md,
209 body_html,
210 )
211 .execute(&mut *conn)
212 .await?;
213
214 Some(thread_id)
215 } else {
216 None
217 };
218
219 Ok(CleanSlateResult {
220 deleted_thread_count: deleted,
221 system_thread_id,
222 })
223 }
224
225 /// Minimal HTML-escape for actor display names embedded in the reset notice.
226 /// We render the notice as a literal HTML string (skipping the markdown
227 /// pipeline) so we don't have to thread renderer config into the mutation.
228 fn html_escape(input: &str) -> String {
229 input
230 .replace('&', "&amp;")
231 .replace('<', "&lt;")
232 .replace('>', "&gt;")
233 .replace('"', "&quot;")
234 .replace('\'', "&#39;")
235 }
236