Skip to main content

max / makenotwork

Multithreaded migration 025: community moderation state machine Owners and platform admins can move a community through four states: active → restricted → frozen → archived (and back). Distinct from platform-admin suspension; this is community-level moderation. State semantics: - active: normal - restricted: only mods/owners can start new threads (replies still open) - frozen: read-only for everyone except mods doing mod actions - archived: frozen + hidden from default listings, surfaces under an explicit ?filter=archived view Also adds Clean Slate: bulk-delete every thread in a community while preserving the community row, categories, memberships, bans, mutes, and tags. Posts a system "Community reset by <actor> on <date>" thread in the first category by sort_order. Atomic via single transaction. Authorization for both transitions and Clean Slate: community Owner/Moderator OR platform admin. Settings page exposes state controls to community-side mods; the platform admin view at /_admin/communities/{slug} adds Clean Slate. Includes the public moderation policy page (server/site-docs/.../guide/ moderation.md) linked from the MT footer.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 17:21 UTC
Commit: edfec9c9ba6a9a4077afa14f1dc3a921e0a7e0df
Parent: 3251f45
16 files changed, +1363 insertions, -25 deletions
@@ -22,6 +22,7 @@
22 22 <a href="{{ mnw_base_url }}/creators">Creators</a>
23 23 <a href="{{ mnw_base_url }}/docs">Docs</a>
24 24 <a href="{{ mnw_base_url }}/policy">Policy</a>
25 + <a href="{{ mnw_base_url }}/guide/moderation">Forum moderation</a>
25 26 </div>
26 27 <span>Powered by <a href="{{ mnw_base_url }}/">Makenot<span class="dot">.</span>work</a></span>
27 28 <span class="footer-sep">&middot;</span>
@@ -15,7 +15,10 @@
15 15
16 16 use mt_core::types::ModAction;
17 17
18 - use super::{log_mod_action, parse_uuid, AdminSearchQuery, SuspendForm};
18 + use super::{
19 + get_community, log_mod_action, parse_uuid, template_user, AdminSearchQuery,
20 + CleanSlateForm, SuspendForm,
21 + };
19 22
20 23 #[tracing::instrument(skip_all)]
21 24 pub(super) async fn admin_dashboard(
@@ -171,3 +174,109 @@
171 174
172 175 Ok(Redirect::to("/_admin?toast=User+unsuspended"))
173 176 }
177 +
178 + // ============================================================================
179 + // Dedicated admin view per community: state machine + clean-slate.
180 + // ============================================================================
181 +
182 + #[tracing::instrument(skip_all)]
183 + pub(super) async fn admin_community_detail(
184 + axum::extract::State(state): axum::extract::State<AppState>,
185 + session: Session,
186 + PlatformAdmin(admin): PlatformAdmin,
187 + Path(slug): Path<String>,
188 + ) -> Result<AdminCommunityTemplate, Response> {
189 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
190 + let community = get_community(&state.db, &slug).await?;
191 +
192 + let thread_count: i64 = sqlx::query_scalar(
193 + "SELECT COUNT(*) FROM threads t
194 + JOIN categories c ON c.id = t.category_id
195 + WHERE c.community_id = $1",
196 + )
197 + .bind(community.id)
198 + .fetch_one(&state.db)
199 + .await
200 + .map_err(|e| {
201 + tracing::error!(error = ?e, "db error counting threads");
202 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
203 + })?;
204 +
205 + let member_count = mt_db::queries::count_community_members(&state.db, community.id)
206 + .await
207 + .map_err(|e| {
208 + tracing::error!(error = ?e, "db error counting members");
209 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
210 + })?;
211 +
212 + let suspension_reason: Option<String> = if community.suspended_at.is_some() {
213 + sqlx::query_scalar("SELECT suspension_reason FROM communities WHERE id = $1")
214 + .bind(community.id)
215 + .fetch_one(&state.db)
216 + .await
217 + .ok()
218 + .flatten()
219 + } else {
220 + None
221 + };
222 +
223 + Ok(AdminCommunityTemplate {
224 + csrf_token,
225 + session_user: Some(template_user(&admin, state.config.platform_admin_id)),
226 + mnw_base_url: state.config.mnw_base_url.clone(),
227 + community_name: community.name,
228 + community_slug: slug,
229 + current_state: community.state.as_str(),
230 + thread_count,
231 + member_count,
232 + is_suspended: community.suspended_at.is_some(),
233 + suspension_reason,
234 + })
235 + }
236 +
237 + #[tracing::instrument(skip_all)]
238 + pub(super) async fn admin_community_clean_slate_handler(
239 + axum::extract::State(state): axum::extract::State<AppState>,
240 + PlatformAdmin(admin): PlatformAdmin,
241 + Path(slug): Path<String>,
242 + Form(form): Form<CleanSlateForm>,
243 + ) -> Result<Redirect, Response> {
244 + let community = get_community(&state.db, &slug).await?;
245 +
246 + // GitHub-style typed-phrase confirmation: must match the community slug
247 + // exactly. Trim only — case is significant.
248 + if form.confirm.trim() != slug {
249 + return Err((
250 + StatusCode::UNPROCESSABLE_ENTITY,
251 + "Confirmation phrase did not match the community slug.",
252 + )
253 + .into_response());
254 + }
255 +
256 + let result = mt_db::mutations::clean_slate_community(
257 + &state.db,
258 + community.id,
259 + admin.user_id,
260 + &admin.username,
261 + )
262 + .await
263 + .map_err(|e| {
264 + tracing::error!(error = ?e, "clean-slate failed");
265 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
266 + })?;
267 +
268 + log_mod_action(
269 + &state.db,
270 + Some(community.id),
271 + admin.user_id,
272 + ModAction::CleanSlateCommunity,
273 + None,
274 + result.system_thread_id,
275 + Some(&format!("deleted {} threads", result.deleted_thread_count)),
276 + )
277 + .await;
278 +
279 + Ok(Redirect::to(&format!(
280 + "/_admin/communities/{slug}?toast=Community+reset"
281 + )))
282 + }
@@ -7,7 +7,7 @@
7 7 use chrono::{DateTime, Duration, Utc};
8 8 use uuid::Uuid;
9 9
10 - use mt_core::types::{CommunityRole, ModAction};
10 + use mt_core::types::{CommunityRole, CommunityState, ModAction};
11 11
12 12 use crate::auth;
13 13 use crate::templates::*;
@@ -313,3 +313,110 @@
313 313 }
314 314 Ok((community, role))
315 315 }
316 +
317 + // ============================================================================
318 + // Superadmin authorization
319 + // ============================================================================
320 +
321 + /// Whether `user` is the configured platform admin.
322 + ///
323 + /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real
324 + /// permissions system is deferred. See `docs/todo.md` § Community Moderation
325 + /// Enforcement.
326 + pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool {
327 + state
328 + .config
329 + .platform_admin_id
330 + .is_some_and(|id| id == user.user_id)
331 + }
332 +
333 + /// True if the user can perform mod actions in this community: either a
334 + /// community Owner/Moderator, or the platform admin (who can act on any
335 + /// community). Used by [`check_community_state`] and by the state-change route.
336 + pub(crate) fn is_mod_or_superadmin(
337 + state: &AppState,
338 + user: &auth::SessionUser,
339 + role: &Option<CommunityRole>,
340 + ) -> bool {
341 + is_mod_or_owner(role) || is_platform_admin(state, user)
342 + }
343 +
344 + /// Fetch community + verify the user is a mod, owner, or platform admin.
345 + ///
346 + /// Returns `(community, role)` — `role` is `None` when the user is the platform
347 + /// admin but holds no role in this specific community.
348 + #[tracing::instrument(skip_all)]
349 + pub(crate) async fn require_mod_or_superadmin(
350 + state: &AppState,
351 + slug: &str,
352 + user: &auth::SessionUser,
353 + ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
354 + let community = get_community(&state.db, slug).await?;
355 + let role = get_role(&state.db, user.user_id, community.id).await?;
356 + if !is_mod_or_superadmin(state, user, &role) {
357 + return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
358 + }
359 + Ok((community, role))
360 + }
361 +
362 + // ============================================================================
363 + // Community state enforcement
364 + // ============================================================================
365 +
366 + /// Whether a write attempt is starting a new thread or extending an existing
367 + /// one. Restricted communities block `NewThread` for non-mods but still accept
368 + /// `ContinueExisting` writes.
369 + #[derive(Debug, Clone, Copy)]
370 + pub(crate) enum WriteScope {
371 + NewThread,
372 + ContinueExisting,
373 + }
374 +
375 + /// Convenience: combine role lookup with [`check_community_state`]. Use this
376 + /// in write handlers that don't already need the role for other purposes.
377 + #[tracing::instrument(skip_all)]
378 + pub(crate) async fn check_write_state(
379 + state: &AppState,
380 + community: &mt_db::queries::CommunityRow,
381 + user: &auth::SessionUser,
382 + scope: WriteScope,
383 + ) -> Result<(), Response> {
384 + let role = get_role(&state.db, user.user_id, community.id).await?;
385 + let is_mod_or_super = is_mod_or_superadmin(state, user, &role);
386 + check_community_state(community.state, scope, is_mod_or_super)
387 + }
388 +
389 + /// Gate a write against the community's [`CommunityState`].
390 + ///
391 + /// Mods/owners and the platform admin bypass all state restrictions. Members
392 + /// follow the state's `allows_*` predicates. Returns 403 with a state-specific
393 + /// message on denial — message text is what the user will see in the toast.
394 + ///
395 + /// Note: this is independent of [`check_write_access`] (which covers
396 + /// suspension/ban/mute). Call both in write handlers.
397 + #[allow(clippy::result_large_err)]
398 + pub(crate) fn check_community_state(
399 + community_state: CommunityState,
400 + scope: WriteScope,
401 + is_mod_or_super: bool,
402 + ) -> Result<(), Response> {
403 + if is_mod_or_super {
404 + return Ok(());
405 + }
406 + let allowed = match scope {
407 + WriteScope::NewThread => community_state.allows_new_threads_for_members(),
408 + WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
409 + };
410 + if allowed {
411 + return Ok(());
412 + }
413 + let msg = match (community_state, scope) {
414 + (CommunityState::Restricted, WriteScope::NewThread) => {
415 + "New threads are restricted in this community."
416 + }
417 + (CommunityState::Frozen, _) => "This community is frozen.",
418 + (CommunityState::Archived, _) => "This community is archived.",
419 + _ => "Action not allowed in the community's current state.",
420 + };
421 + Err((StatusCode::FORBIDDEN, msg).into_response())
422 + }