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
@@ -58,6 +58,78 @@ impl fmt::Display for CommunityRole {
58 58 }
59 59
60 60 // ============================================================================
61 + // CommunityState — moderation state machine
62 + // ============================================================================
63 +
64 + /// Community-level moderation state. Distinct from platform suspension
65 + /// (`communities.suspended_at`); this is what owners/mods control.
66 + ///
67 + /// - `Active`: normal operation.
68 + /// - `Restricted`: only mods/owners can start new threads. Existing threads
69 + /// still accept replies.
70 + /// - `Frozen`: read-only for everyone except mods performing mod actions.
71 + /// - `Archived`: behaves like `Frozen` and is hidden from default community
72 + /// listings (surfaces under an explicit archived filter). Reactivation =
73 + /// transition back to `Active`.
74 + #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
75 + #[sqlx(type_name = "TEXT", rename_all = "lowercase")]
76 + pub enum CommunityState {
77 + Active,
78 + Restricted,
79 + Frozen,
80 + Archived,
81 + }
82 +
83 + impl Serialize for CommunityState {
84 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
85 + serializer.serialize_str(self.as_str())
86 + }
87 + }
88 +
89 + impl CommunityState {
90 + pub fn from_db(s: &str) -> Option<Self> {
91 + match s {
92 + "active" => Some(Self::Active),
93 + "restricted" => Some(Self::Restricted),
94 + "frozen" => Some(Self::Frozen),
95 + "archived" => Some(Self::Archived),
96 + _ => None,
97 + }
98 + }
99 +
100 + pub fn as_str(self) -> &'static str {
101 + match self {
102 + Self::Active => "active",
103 + Self::Restricted => "restricted",
104 + Self::Frozen => "frozen",
105 + Self::Archived => "archived",
106 + }
107 + }
108 +
109 + /// Hidden from default community listings (still reachable by direct URL).
110 + pub fn is_hidden_from_default_listing(self) -> bool {
111 + matches!(self, Self::Archived)
112 + }
113 +
114 + /// Whether a non-mod is allowed to start a new thread in this state.
115 + pub fn allows_new_threads_for_members(self) -> bool {
116 + matches!(self, Self::Active)
117 + }
118 +
119 + /// Whether a non-mod is allowed to reply / edit / otherwise write in
120 + /// this state.
121 + pub fn allows_writes_for_members(self) -> bool {
122 + matches!(self, Self::Active | Self::Restricted)
123 + }
124 + }
125 +
126 + impl fmt::Display for CommunityState {
127 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 + f.write_str(self.as_str())
129 + }
130 + }
131 +
132 + // ============================================================================
61 133 // BanType — ban or mute
62 134 // ============================================================================
63 135
@@ -112,6 +184,8 @@ pub enum ModAction {
112 184 AutoHidePost,
113 185 RemovePostViaFlag,
114 186 RemoveImage,
187 + ChangeCommunityState,
188 + CleanSlateCommunity,
115 189 }
116 190
117 191 impl ModAction {
@@ -137,6 +211,8 @@ impl ModAction {
137 211 Self::AutoHidePost => "auto_hide_post",
138 212 Self::RemovePostViaFlag => "remove_post_via_flag",
139 213 Self::RemoveImage => "remove_image",
214 + Self::ChangeCommunityState => "change_community_state",
215 + Self::CleanSlateCommunity => "clean_slate_community",
140 216 }
141 217 }
142 218 }
@@ -249,6 +325,38 @@ mod tests {
249 325 }
250 326
251 327 #[test]
328 + fn community_state_roundtrip() {
329 + for s in [
330 + CommunityState::Active,
331 + CommunityState::Restricted,
332 + CommunityState::Frozen,
333 + CommunityState::Archived,
334 + ] {
335 + assert_eq!(CommunityState::from_db(s.as_str()), Some(s));
336 + }
337 + assert_eq!(CommunityState::from_db("bogus"), None);
338 + }
339 +
340 + #[test]
341 + fn community_state_predicates() {
342 + assert!(CommunityState::Active.allows_new_threads_for_members());
343 + assert!(CommunityState::Active.allows_writes_for_members());
344 +
345 + // Restricted blocks new threads but allows replies.
346 + assert!(!CommunityState::Restricted.allows_new_threads_for_members());
347 + assert!(CommunityState::Restricted.allows_writes_for_members());
348 +
349 + // Frozen blocks all member writes.
350 + assert!(!CommunityState::Frozen.allows_new_threads_for_members());
351 + assert!(!CommunityState::Frozen.allows_writes_for_members());
352 +
353 + // Archived blocks writes and hides from default listings.
354 + assert!(!CommunityState::Archived.allows_writes_for_members());
355 + assert!(CommunityState::Archived.is_hidden_from_default_listing());
356 + assert!(!CommunityState::Active.is_hidden_from_default_listing());
357 + }
358 +
359 + #[test]
252 360 fn mod_action_display() {
253 361 assert_eq!(ModAction::PinThread.as_str(), "pin_thread");
254 362 assert_eq!(ModAction::Ban.as_str(), "ban");
@@ -1,7 +1,7 @@
1 1 //! Database write mutations — inserts, updates, deletes.
2 2
3 3 use chrono::{DateTime, Utc};
4 - use mt_core::types::{BanType, ModAction};
4 + use mt_core::types::{BanType, CommunityState, ModAction};
5 5 use sqlx::PgPool;
6 6 use uuid::Uuid;
7 7
@@ -536,6 +536,132 @@ pub async fn unsuspend_community(
536 536 Ok(())
537 537 }
538 538
539 + /// Set the community moderation state. See [`CommunityState`] for semantics.
540 + #[tracing::instrument(skip_all)]
541 + pub async fn set_community_state(
542 + pool: &PgPool,
543 + community_id: Uuid,
544 + state: CommunityState,
545 + ) -> Result<(), sqlx::Error> {
546 + sqlx::query("UPDATE communities SET state = $2 WHERE id = $1")
547 + .bind(community_id)
548 + .bind(state.as_str())
549 + .execute(pool)
550 + .await?;
551 + Ok(())
552 + }
553 +
554 + /// Result of a clean-slate operation.
555 + pub struct CleanSlateResult {
556 + /// Number of threads deleted (excluding the system reset thread that's
557 + /// then inserted). Useful for the success toast.
558 + pub deleted_thread_count: i64,
559 + /// ID of the system "Community reset" thread created in the first
560 + /// category. `None` if the community has no categories — clean-slate
561 + /// still deletes threads but has nowhere to post the notice.
562 + pub system_thread_id: Option<Uuid>,
563 + }
564 +
565 + /// Clean-slate a community: delete all threads (and the posts / footnotes /
566 + /// endorsements / flags / read-positions that cascade from them) while
567 + /// preserving the community row, categories, memberships, bans, mutes, and
568 + /// tags. Posts a system thread "Community reset by &lt;actor&gt; on &lt;date&gt;"
569 + /// in the first category by `sort_order`.
570 + ///
571 + /// Authorization is the caller's responsibility (see `routes/admin.rs`); this
572 + /// mutation only enforces atomicity.
573 + #[tracing::instrument(skip_all)]
574 + pub async fn clean_slate_community(
575 + pool: &PgPool,
576 + community_id: Uuid,
577 + actor_id: Uuid,
578 + actor_display: &str,
579 + ) -> Result<CleanSlateResult, sqlx::Error> {
580 + let mut tx = pool.begin().await?;
581 +
582 + // Delete every thread whose category belongs to this community. Cascades
583 + // reap posts, footnotes, endorsements, flags, read-positions, link
584 + // previews, mentions, and tag joins.
585 + let deleted: i64 = sqlx::query_scalar(
586 + "WITH d AS (
587 + DELETE FROM threads
588 + WHERE category_id IN (SELECT id FROM categories WHERE community_id = $1)
589 + RETURNING 1
590 + )
591 + SELECT COUNT(*) FROM d",
592 + )
593 + .bind(community_id)
594 + .fetch_one(&mut *tx)
595 + .await?;
596 +
597 + // Pick the first category by sort_order to host the reset notice. None
598 + // means the community has no categories — nothing to post into.
599 + let first_category: Option<Uuid> = sqlx::query_scalar(
600 + "SELECT id FROM categories
601 + WHERE community_id = $1
602 + ORDER BY sort_order
603 + LIMIT 1",
604 + )
605 + .bind(community_id)
606 + .fetch_optional(&mut *tx)
607 + .await?;
608 +
609 + let system_thread_id = if let Some(cat_id) = first_category {
610 + let now = Utc::now();
611 + let date = now.format("%Y-%m-%d").to_string();
612 + let title = format!("Community reset by {actor_display} on {date}");
613 + let body_md = format!(
614 + "This community was reset by **{actor_display}** on {date}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.",
615 + );
616 + let body_html = format!(
617 + "<p>This community was reset by <strong>{}</strong> on {}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.</p>",
618 + html_escape(actor_display),
619 + date,
620 + );
621 +
622 + let thread_id: (Uuid,) = sqlx::query_as(
623 + "INSERT INTO threads (category_id, author_id, title, pinned, locked)
624 + VALUES ($1, $2, $3, TRUE, TRUE)
625 + RETURNING id",
626 + )
627 + .bind(cat_id)
628 + .bind(actor_id)
629 + .bind(&title)
630 + .fetch_one(&mut *tx)
631 + .await?;
632 +
633 + sqlx::query(
634 + "INSERT INTO posts (thread_id, author_id, body_markdown, body_html)
635 + VALUES ($1, $2, $3, $4)",
636 + )
637 + .bind(thread_id.0)
638 + .bind(actor_id)
639 + .bind(&body_md)
640 + .bind(&body_html)
641 + .execute(&mut *tx)
642 + .await?;
643 +
644 + Some(thread_id.0)
645 + } else {
646 + None
647 + };
648 +
649 + tx.commit().await?;
650 + Ok(CleanSlateResult { deleted_thread_count: deleted, system_thread_id })
651 + }
652 +
653 + /// Minimal HTML-escape for actor display names embedded in the reset notice.
654 + /// We render the notice as a literal HTML string (skipping the markdown
655 + /// pipeline) so we don't have to thread renderer config into the mutation.
656 + fn html_escape(input: &str) -> String {
657 + input
658 + .replace('&', "&amp;")
659 + .replace('<', "&lt;")
660 + .replace('>', "&gt;")
661 + .replace('"', "&quot;")
662 + .replace('\'', "&#39;")
663 + }
664 +
539 665 /// Suspend a user.
540 666 #[tracing::instrument(skip_all)]
541 667 pub async fn suspend_user(
@@ -1,7 +1,7 @@
1 1 //! Database read queries — projection structs and SQL.
2 2
3 3 use chrono::{DateTime, Utc};
4 - use mt_core::types::{BanType, CommunityRole, ModAction};
4 + use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction};
5 5 use sqlx::PgPool;
6 6 use uuid::Uuid;
7 7
@@ -17,6 +17,7 @@ pub struct CommunityRow {
17 17 pub description: Option<String>,
18 18 pub suspended_at: Option<DateTime<Utc>>,
19 19 pub auto_hide_threshold: Option<i32>,
20 + pub state: CommunityState,
20 21 }
21 22
22 23 #[derive(sqlx::FromRow)]
@@ -167,7 +168,10 @@ pub struct CommunityListRow {
167 168 pub thread_count: i64,
168 169 }
169 170
170 - /// List non-suspended communities with category and thread counts (paginated).
171 + /// List non-suspended, non-archived communities with category and thread counts (paginated).
172 + ///
173 + /// Archived communities are hidden from the default listing — see
174 + /// [`list_archived_communities`] for the explicit archived view.
171 175 #[tracing::instrument(skip_all)]
172 176 pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> {
173 177 sqlx::query_as::<_, CommunityListRow>(
@@ -178,6 +182,7 @@ pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<
178 182 LEFT JOIN categories c ON c.community_id = co.id
179 183 LEFT JOIN threads t ON t.category_id = c.id
180 184 WHERE co.suspended_at IS NULL
185 + AND co.state <> 'archived'
181 186 GROUP BY co.id
182 187 ORDER BY co.name
183 188 LIMIT $1 OFFSET $2",
@@ -188,12 +193,50 @@ pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<
188 193 .await
189 194 }
190 195
191 - /// Count non-suspended communities.
196 + /// Count non-suspended, non-archived communities.
192 197 #[tracing::instrument(skip_all)]
193 198 pub async fn count_communities(pool: &PgPool) -> Result<i64, sqlx::Error> {
194 - sqlx::query_scalar("SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL")
195 - .fetch_one(pool)
196 - .await
199 + sqlx::query_scalar(
200 + "SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL AND state <> 'archived'",
201 + )
202 + .fetch_one(pool)
203 + .await
204 + }
205 +
206 + /// List archived communities. Used by the explicit `?filter=archived` view; never
207 + /// merged with the default listing.
208 + #[tracing::instrument(skip_all)]
209 + pub async fn list_archived_communities(
210 + pool: &PgPool,
211 + limit: i64,
212 + offset: i64,
213 + ) -> Result<Vec<CommunityListRow>, sqlx::Error> {
214 + sqlx::query_as::<_, CommunityListRow>(
215 + "SELECT co.name, co.slug, co.description,
216 + COUNT(DISTINCT c.id) AS category_count,
217 + COUNT(DISTINCT t.id) AS thread_count
218 + FROM communities co
219 + LEFT JOIN categories c ON c.community_id = co.id
220 + LEFT JOIN threads t ON t.category_id = c.id
221 + WHERE co.suspended_at IS NULL
222 + AND co.state = 'archived'
223 + GROUP BY co.id
224 + ORDER BY co.name
225 + LIMIT $1 OFFSET $2",
226 + )
227 + .bind(limit)
228 + .bind(offset)
229 + .fetch_all(pool)
230 + .await
231 + }
232 +
233 + #[tracing::instrument(skip_all)]
234 + pub async fn count_archived_communities(pool: &PgPool) -> Result<i64, sqlx::Error> {
235 + sqlx::query_scalar(
236 + "SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL AND state = 'archived'",
237 + )
238 + .fetch_one(pool)
239 + .await
197 240 }
198 241
199 242 #[tracing::instrument(skip_all)]
@@ -202,7 +245,8 @@ pub async fn get_community_by_slug(
202 245 slug: &str,
203 246 ) -> Result<Option<CommunityRow>, sqlx::Error> {
204 247 sqlx::query_as::<_, CommunityRow>(
205 - "SELECT id, name, slug, description, suspended_at, auto_hide_threshold FROM communities WHERE slug = $1",
248 + "SELECT id, name, slug, description, suspended_at, auto_hide_threshold, state
249 + FROM communities WHERE slug = $1",
206 250 )
207 251 .bind(slug)
208 252 .fetch_optional(pool)
@@ -0,0 +1,23 @@
1 + -- Community moderation state machine.
2 + --
3 + -- Distinct from `suspended_at` (platform-admin action). This is community-level
4 + -- moderation by owners/mods/superadmin.
5 + --
6 + -- States:
7 + -- active — normal operation (default)
8 + -- restricted — block new thread creation for non-mods (existing threads still accept replies)
9 + -- frozen — read-only for everyone except mods doing mod actions
10 + -- archived — frozen + hidden from default community listings; surfaces under
11 + -- an explicit archived filter; reactivation = set back to active
12 + --
13 + -- Authorization for transitions: community Owner/Moderator OR platform admin.
14 +
15 + ALTER TABLE communities
16 + ADD COLUMN state TEXT NOT NULL DEFAULT 'active'
17 + CHECK (state IN ('active', 'restricted', 'frozen', 'archived'));
18 +
19 + -- Partial index for the archived filter view — most communities are active, so
20 + -- a partial index keeps the listing query cheap.
21 + CREATE INDEX IF NOT EXISTS idx_communities_archived
22 + ON communities (name)
23 + WHERE state = 'archived';
@@ -15,7 +15,10 @@ use crate::AppState;
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 @@ pub(super) async fn unsuspend_user_handler(
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 + }
@@ -147,6 +147,8 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
147 147 // Check community access (suspension + ban) — no mute check since endorsing is not content
148 148 let community = get_community(&state.db, &slug).await?;
149 149 check_community_access(&state.db, &community, Some(user.user_id)).await?;
150 + // Endorsement is a write action, so it's blocked by Frozen/Archived state.
151 + check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
150 152
151 153 // Check platform suspension
152 154 let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id)
@@ -19,28 +19,39 @@ use mt_core::types::{SortColumn, SortOrder};
19 19
20 20 use super::super::{
21 21 check_community_access, get_community, get_role, is_mod_or_owner, is_owner,
22 - parse_uuid, template_user, CategoryQuery, PageQuery,
22 + parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery,
23 23 };
24 24
25 25 /// Forum directory — lists local communities (paginated).
26 + ///
27 + /// `?filter=archived` switches to the archived-only listing. The default view
28 + /// excludes archived communities; they remain reachable by direct URL.
26 29 #[tracing::instrument(skip_all)]
27 30 pub(in crate::routes) async fn forum_directory(
28 31 axum::extract::State(state): axum::extract::State<AppState>,
29 - Query(page_query): Query<PageQuery>,
32 + Query(query): Query<ForumDirectoryQuery>,
30 33 session: Session,
31 34 MaybeUser(session_user): MaybeUser,
32 35 ) -> impl IntoResponse {
33 36 let csrf_token = Some(csrf::get_or_create_token(&session).await);
37 + let viewing_archived = query.filter.as_deref() == Some("archived");
34 38
35 39 let per_page: i64 = 25;
36 - let total = mt_db::queries::count_communities(&state.db)
37 - .await
38 - .unwrap_or(0);
39 - let pagination = Pagination::new(page_query.page.unwrap_or(1), total, per_page);
40 + let total = if viewing_archived {
41 + mt_db::queries::count_archived_communities(&state.db).await
42 + } else {
43 + mt_db::queries::count_communities(&state.db).await
44 + }
45 + .unwrap_or(0);
46 + let pagination = Pagination::new(query.page.unwrap_or(1), total, per_page);
40 47 let offset = (pagination.current_page as i64 - 1) * per_page;
41 48
42 - let communities = mt_db::queries::list_communities(&state.db, per_page, offset)
43 - .await
49 + let rows = if viewing_archived {
50 + mt_db::queries::list_archived_communities(&state.db, per_page, offset).await
51 + } else {
52 + mt_db::queries::list_communities(&state.db, per_page, offset).await
53 + };
54 + let communities = rows
44 55 .unwrap_or_default()
45 56 .into_iter()
46 57 .map(|c| CommunityDirectoryRow {
@@ -60,6 +71,7 @@ pub(in crate::routes) async fn forum_directory(
60 71 mnw_base_url: state.config.mnw_base_url.clone(),
61 72 communities,
62 73 pagination,
74 + viewing_archived,
63 75 }
64 76 }
65 77
@@ -7,7 +7,7 @@ use axum::{
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 @@ pub(crate) async fn require_mod_or_owner(
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 + }
@@ -13,12 +13,12 @@ use crate::csrf;
13 13 use crate::templates::*;
14 14 use crate::AppState;
15 15
16 - use mt_core::types::ModAction;
16 + use mt_core::types::{CommunityState, ModAction};
17 17
18 18 use super::{
19 - log_mod_action, parse_uuid, require_owner, template_user, validate_title,
20 - CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm,
21 - UpdateCommunityForm,
19 + log_mod_action, parse_uuid, require_mod_or_superadmin, require_owner, template_user,
20 + validate_title, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData,
21 + MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm,
22 22 };
23 23
24 24 #[tracing::instrument(skip_all)]
@@ -390,3 +390,59 @@ pub(super) async fn delete_tag_handler(
390 390 "/p/{slug}/settings?toast=Tag+deleted"
391 391 )))
392 392 }
393 +
394 + /// `POST /p/{slug}/settings/state` — change community moderation state.
395 + ///
396 + /// Authorized for community Owner, Moderator, or platform admin. Transition
397 + /// to/from any state is allowed; semantics live in [`CommunityState`].
398 + ///
399 + /// Logged as `ModAction::ChangeCommunityState` for audit. Returns 422 for an
400 + /// unknown state value (anything other than the four documented states).
401 + #[tracing::instrument(skip_all)]
402 + pub(super) async fn set_community_state_handler(
403 + axum::extract::State(state): axum::extract::State<AppState>,
404 + Path(slug): Path<String>,
405 + MaybeUser(session_user): MaybeUser,
406 + Form(form): Form<SetCommunityStateForm>,
407 + ) -> Result<Redirect, Response> {
408 + let user = session_user
409 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
410 +
411 + let (community, _role) = require_mod_or_superadmin(&state, &slug, &user).await?;
412 +
413 + let new_state = CommunityState::from_db(form.state.trim()).ok_or_else(|| {
414 + (
415 + StatusCode::UNPROCESSABLE_ENTITY,
416 + "Unknown community state.",
417 + )
418 + .into_response()
419 + })?;
420 +
421 + if new_state == community.state {
422 + return Ok(Redirect::to(&format!(
423 + "/p/{slug}/settings?toast=No+change"
424 + )));
425 + }
426 +
427 + mt_db::mutations::set_community_state(&state.db, community.id, new_state)
428 + .await
429 + .map_err(|e| {
430 + tracing::error!(error = ?e, "db error setting community state");
431 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
432 + })?;
433 +
434 + log_mod_action(
435 + &state.db,
436 + Some(community.id),
437 + user.user_id,
438 + ModAction::ChangeCommunityState,
439 + None,
440 + None,
441 + Some(new_state.as_str()),
442 + )
443 + .await;
444 +
445 + Ok(Redirect::to(&format!(
446 + "/p/{slug}/settings?toast=Community+state+updated"
447 + )))
448 + }
@@ -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>
@@ -88,7 +88,7 @@
88 88 <tbody>
89 89 {% for c in communities %}
90 90 <tr>
91 - <td>{{ c.name }}</td>
91 + <td><a href="/_admin/communities/{{ c.slug }}">{{ c.name }}</a></td>
92 92 <td class="settings-mono">{{ c.slug }}</td>
93 93 <td>
94 94 {% if c.is_suspended %}
@@ -0,0 +1,80 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ community_name }} — Admin — Multithreaded{% endblock %}
4 +
5 + {% block head %}<meta name="robots" content="noindex">{% endblock %}
6 +
7 + {% block header %}{% include "partials/site_header.html" %}{% endblock %}
8 +
9 + {% block content %}
10 + <div class="container">
11 + <div class="breadcrumb">
12 + <a href="/_admin">Admin</a>
13 + <span class="sep">/</span>
14 + <a href="/p/{{ community_slug }}">{{ community_name }}</a>
15 + </div>
16 + <h2 class="section-heading">Community administration — {{ community_name }}</h2>
17 +
18 + <div class="settings-section">
19 + <h3>Overview</h3>
20 + <ul class="account-status">
21 + <li>Slug: <strong>{{ community_slug }}</strong></li>
22 + <li>State: <strong>{{ current_state }}</strong></li>
23 + <li>Suspension:
24 + {% if is_suspended %}
25 + <strong>suspended</strong>
26 + {% if let Some(reason) = suspension_reason %} — {{ reason }}{% endif %}
27 + {% else %}
28 + none
29 + {% endif %}
30 + </li>
31 + <li>Threads (current): <strong>{{ thread_count }}</strong></li>
32 + <li>Members: <strong>{{ member_count }}</strong></li>
33 + </ul>
34 + </div>
35 +
36 + <div class="settings-section">
37 + <h3>Moderation state</h3>
38 + <p class="form-help">
39 + Change the community's state machine. Mods can also do this from the community
40 + settings page; admin changes are logged the same way.
41 + </p>
42 + <form method="post" action="/p/{{ community_slug }}/settings/state" class="form-container">
43 + <div class="form-group">
44 + <label for="state">State</label>
45 + <select id="state" name="state">
46 + <option value="active" {% if current_state == "active" %}selected{% endif %}>Active — normal operation</option>
47 + <option value="restricted" {% if current_state == "restricted" %}selected{% endif %}>Restricted — only mods can start new threads</option>
48 + <option value="frozen" {% if current_state == "frozen" %}selected{% endif %}>Frozen — read-only for non-mods</option>
49 + <option value="archived" {% if current_state == "archived" %}selected{% endif %}>Archived — frozen + hidden from default listing</option>
50 + </select>
51 + </div>
52 + <div class="form-actions">
53 + <button type="submit" class="primary">Update state</button>
54 + </div>
55 + </form>
56 + </div>
57 +
58 + <div class="settings-section danger-zone">
59 + <h3>Clean slate</h3>
60 + <p class="form-help">
61 + Delete every thread, post, footnote, endorsement, and flag in this community.
62 + Settings, categories, members, and bans are preserved. A pinned, locked notice
63 + thread is posted in the first category recording who reset the forum and when.
64 + </p>
65 + <p class="form-help">
66 + <strong>This cannot be undone.</strong> To confirm, type the community slug
67 + <code>{{ community_slug }}</code> in the box below.
68 + </p>
69 + <form method="post" action="/_admin/communities/{{ community_slug }}/clean-slate" class="form-container">
70 + <div class="form-group">
71 + <label for="confirm">Confirmation</label>
72 + <input type="text" id="confirm" name="confirm" placeholder="{{ community_slug }}" autocomplete="off" required>
73 + </div>
74 + <div class="form-actions">
75 + <button type="submit" class="danger">Wipe community threads</button>
76 + </div>
77 + </form>
78 + </div>
79 + </div>
80 + {% endblock %}
@@ -9,10 +9,19 @@
9 9 {% block content %}
10 10 <div class="container">
11 11 <div class="page-header">
12 - <h1>Forums</h1>
12 + <h1>{% if viewing_archived %}Archived forums{% else %}Forums{% endif %}</h1>
13 + <div class="page-header-actions">
14 + {% if viewing_archived %}
15 + <a href="/">Back to active forums</a>
16 + {% else %}
17 + <a href="/?filter=archived">View archived</a>
18 + {% endif %}
19 + </div>
13 20 </div>
14 21 {% if communities.is_empty() %}
15 - <div class="empty-state">No communities yet.</div>
22 + <div class="empty-state">
23 + {% if viewing_archived %}No archived communities.{% else %}No communities yet.{% endif %}
24 + </div>
16 25 {% else %}
17 26 <table class="directory-table">
18 27 <thead>
@@ -0,0 +1,216 @@
1 + //! Dedicated superadmin community view + clean-slate.
2 + //!
3 + //! Covers:
4 + //! * Admin community detail page renders for superadmin; 404 for others
5 + //! * Clean-slate wipes threads/posts, preserves settings/categories/members,
6 + //! posts a pinned+locked system thread, logs the mod action
7 + //! * Typed-phrase confirmation enforced
8 +
9 + use crate::harness::TestHarness;
10 + use axum::http::StatusCode;
11 + use uuid::Uuid;
12 +
13 + async fn login_admin(h: &mut TestHarness, admin_id: Uuid) {
14 + sqlx::query(
15 + "INSERT INTO users (mnw_account_id, username, display_name) \
16 + VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
17 + )
18 + .bind(admin_id)
19 + .execute(&h.db)
20 + .await
21 + .unwrap();
22 + h.client.get("/").await;
23 + h.client
24 + .post_json(
25 + "/_test/login",
26 + &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
27 + .to_string(),
28 + )
29 + .await;
30 + }
31 +
32 + #[tokio::test]
33 + async fn admin_community_page_renders_for_superadmin() {
34 + let admin_id = Uuid::new_v4();
35 + let mut h = TestHarness::new_with_admin(admin_id).await;
36 + let comm_id = h.create_community("Test", "test").await;
37 + let _cat_id = h.create_category(comm_id, "General", "general").await;
38 + login_admin(&mut h, admin_id).await;
39 +
40 + let resp = h.client.get("/_admin/communities/test").await;
41 + assert_eq!(resp.status, StatusCode::OK);
42 + assert!(resp.text.contains("Community administration"));
43 + assert!(resp.text.contains("Clean slate"));
44 + assert!(resp.text.contains("Moderation state"));
45 + }
46 +
47 + #[tokio::test]
48 + async fn admin_community_page_404_for_non_admin() {
49 + let mut h = TestHarness::new().await;
50 + let comm_id = h.create_community("Test", "test").await;
51 + let _cat_id = h.create_category(comm_id, "General", "general").await;
52 + let _ = h.login_as("notadmin").await;
53 +
54 + let resp = h.client.get("/_admin/communities/test").await;
55 + assert_eq!(resp.status, StatusCode::NOT_FOUND);
56 + }
57 +
58 + #[tokio::test]
59 + async fn clean_slate_wipes_threads_and_logs_action() {
60 + let admin_id = Uuid::new_v4();
61 + let mut h = TestHarness::new_with_admin(admin_id).await;
62 + let comm_id = h.create_community("Test", "test").await;
63 + let cat_id = h.create_category(comm_id, "General", "general").await;
64 +
65 + // Seed an author + threads to wipe.
66 + let author_id = h.login_as("seedauthor").await;
67 + h.add_membership(author_id, comm_id, "member").await;
68 + let _thread_a = h.create_thread_with_post(cat_id, author_id, "First", "body A").await;
69 + let _thread_b = h.create_thread_with_post(cat_id, author_id, "Second", "body B").await;
70 + h.client.post_form("/auth/logout", "").await;
71 +
72 + login_admin(&mut h, admin_id).await;
73 + h.client.get("/_admin/communities/test").await;
74 + let resp = h
75 + .client
76 + .post_form("/_admin/communities/test/clean-slate", "confirm=test")
77 + .await;
78 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
79 +
80 + // Originals gone; one system thread remains (pinned + locked).
81 + let titles: Vec<(String, bool, bool)> = sqlx::query_as(
82 + "SELECT t.title, t.pinned, t.locked
83 + FROM threads t JOIN categories c ON c.id = t.category_id
84 + WHERE c.community_id = $1",
85 + )
86 + .bind(comm_id)
87 + .fetch_all(&h.db)
88 + .await
89 + .unwrap();
90 + assert_eq!(titles.len(), 1, "expected only the system reset thread");
91 + let (title, pinned, locked) = &titles[0];
92 + assert!(title.starts_with("Community reset by superadmin"));
93 + assert!(*pinned, "system thread should be pinned");
94 + assert!(*locked, "system thread should be locked");
95 +
96 + // Mod log records the action.
97 + let action_count: i64 = sqlx::query_scalar(
98 + "SELECT COUNT(*) FROM mod_log
99 + WHERE community_id = $1 AND action = 'clean_slate_community'",
100 + )
101 + .bind(comm_id)
102 + .fetch_one(&h.db)
103 + .await
104 + .unwrap();
105 + assert_eq!(action_count, 1);
106 + }
107 +
108 + #[tokio::test]
109 + async fn clean_slate_preserves_categories_members_tags() {
110 + let admin_id = Uuid::new_v4();
111 + let mut h = TestHarness::new_with_admin(admin_id).await;
112 + let comm_id = h.create_community("Test", "test").await;
113 + let cat_id = h.create_category(comm_id, "General", "general").await;
114 +
115 + let author_id = h.login_as("seed2").await;
116 + h.add_membership(author_id, comm_id, "member").await;
117 + h.create_thread_with_post(cat_id, author_id, "First", "body").await;
118 + h.client.post_form("/auth/logout", "").await;
119 +
120 + login_admin(&mut h, admin_id).await;
121 + h.client.get("/_admin/communities/test").await;
122 + h.client
123 + .post_form("/_admin/communities/test/clean-slate", "confirm=test")
124 + .await;
125 +
126 + // Category survives.
127 + let cat_count: i64 =
128 + sqlx::query_scalar("SELECT COUNT(*) FROM categories WHERE community_id = $1")
129 + .bind(comm_id)
130 + .fetch_one(&h.db)
131 + .await
132 + .unwrap();
133 + assert!(cat_count >= 1);
134 +
135 + // Membership survives.
136 + let member_count: i64 = sqlx::query_scalar(
137 + "SELECT COUNT(*) FROM memberships WHERE community_id = $1 AND user_id = $2",
138 + )
139 + .bind(comm_id)
140 + .bind(author_id)
141 + .fetch_one(&h.db)
142 + .await
143 + .unwrap();
144 + assert_eq!(member_count, 1, "membership should be preserved");
145 + }
146 +
147 + #[tokio::test]
148 + async fn clean_slate_rejects_wrong_confirmation_phrase() {
149 + let admin_id = Uuid::new_v4();
150 + let mut h = TestHarness::new_with_admin(admin_id).await;
151 + let comm_id = h.create_community("Test", "test").await;
152 + let cat_id = h.create_category(comm_id, "General", "general").await;
153 + let author_id = h.login_as("seed3").await;
154 + h.add_membership(author_id, comm_id, "member").await;
155 + h.create_thread_with_post(cat_id, author_id, "Survive me", "body").await;
156 + h.client.post_form("/auth/logout", "").await;
157 +
158 + login_admin(&mut h, admin_id).await;
159 + h.client.get("/_admin/communities/test").await;
160 + let resp = h
161 + .client
162 + .post_form("/_admin/communities/test/clean-slate", "confirm=nope")
163 + .await;
164 + assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
165 +
166 + // Threads survive.
167 + let count: i64 = sqlx::query_scalar(
168 + "SELECT COUNT(*) FROM threads t JOIN categories c ON c.id = t.category_id
169 + WHERE c.community_id = $1",
170 + )
171 + .bind(comm_id)
172 + .fetch_one(&h.db)
173 + .await
174 + .unwrap();
175 + assert_eq!(count, 1, "thread should still exist after rejected clean-slate");
176 + }
177 +
178 + #[tokio::test]
179 + async fn clean_slate_404_for_non_admin() {
180 + let mut h = TestHarness::new().await;
181 + let comm_id = h.create_community("Test", "test").await;
182 + let _cat_id = h.create_category(comm_id, "General", "general").await;
183 + let _ = h.login_as("notadmin").await;
184 +
185 + h.client.get("/").await;
186 + let resp = h
187 + .client
188 + .post_form("/_admin/communities/test/clean-slate", "confirm=test")
189 + .await;
190 + assert_eq!(resp.status, StatusCode::NOT_FOUND);
191 + }
192 +
193 + #[tokio::test]
194 + async fn clean_slate_with_no_categories_skips_system_thread() {
195 + let admin_id = Uuid::new_v4();
196 + let mut h = TestHarness::new_with_admin(admin_id).await;
197 + let comm_id = h.create_community("Empty", "empty").await;
198 + // No category — clean-slate should succeed but skip the system thread.
199 + login_admin(&mut h, admin_id).await;
200 + h.client.get("/_admin/communities/empty").await;
201 + let resp = h
202 + .client
203 + .post_form("/_admin/communities/empty/clean-slate", "confirm=empty")
204 + .await;
205 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
206 +
207 + let count: i64 = sqlx::query_scalar(
208 + "SELECT COUNT(*) FROM threads t JOIN categories c ON c.id = t.category_id
209 + WHERE c.community_id = $1",
210 + )
211 + .bind(comm_id)
212 + .fetch_one(&h.db)
213 + .await
214 + .unwrap();
215 + assert_eq!(count, 0);
216 + }
@@ -0,0 +1,379 @@
1 + //! Community moderation state machine — enforcement and state-change tests.
2 + //!
3 + //! Predicate semantics (which actions are allowed in each state) live in
4 + //! `mt-core::types::CommunityState` unit tests. These tests exercise the
5 + //! wire-up: that write handlers consult the state, mods/superadmin bypass,
6 + //! and the state-change route is authorized correctly.
7 +
8 + use crate::harness::TestHarness;
9 + use axum::http::StatusCode;
10 + use uuid::Uuid;
11 +
12 + // ── Setup ──
13 +
14 + /// Build a community in the requested state with a category, returning
15 + /// (community_id, category_id).
16 + async fn setup_community(h: &mut TestHarness, state: &str) -> (Uuid, Uuid) {
17 + let comm_id = h.create_community("Test", "test").await;
18 + let cat_id = h.create_category(comm_id, "General", "general").await;
19 + sqlx::query("UPDATE communities SET state = $2 WHERE id = $1")
20 + .bind(comm_id)
21 + .bind(state)
22 + .execute(&h.db)
23 + .await
24 + .expect("set state");
25 + (comm_id, cat_id)
26 + }
27 +
28 + // ── Restricted: block new threads, allow replies ──
29 +
30 + #[tokio::test]
31 + async fn restricted_blocks_member_new_thread() {
32 + let mut h = TestHarness::new().await;
33 + let user_id = h.login_as("rmem").await;
34 + let (comm_id, _cat) = setup_community(&mut h, "restricted").await;
35 + h.add_membership(user_id, comm_id, "member").await;
36 +
37 + h.client.get("/p/test/general/new").await;
38 + let resp = h
39 + .client
40 + .post_form("/p/test/general/new", "title=Blocked&body=No")
41 + .await;
42 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
43 + assert!(resp.text.contains("restricted"), "body: {}", resp.text);
44 + }
45 +
46 + #[tokio::test]
47 + async fn restricted_allows_member_reply() {
48 + let mut h = TestHarness::new().await;
49 + let user_id = h.login_as("rmem2").await;
50 + let (comm_id, cat_id) = setup_community(&mut h, "restricted").await;
51 + h.add_membership(user_id, comm_id, "member").await;
52 + let thread_id = h
53 + .create_thread_with_post(cat_id, user_id, "Existing", "OP")
54 + .await;
55 +
56 + let url = format!("/p/test/general/{}", thread_id);
57 + h.client.get(&url).await;
58 + let resp = h
59 + .client
60 + .post_form(&format!("{}/reply", url), "body=Hello+still")
61 + .await;
62 + assert!(resp.status.is_redirection() || resp.status.is_success(), "status: {}", resp.status);
63 + }
64 +
65 + #[tokio::test]
66 + async fn restricted_mod_can_create_thread() {
67 + let mut h = TestHarness::new().await;
68 + let user_id = h.login_as("rmod").await;
69 + let (comm_id, _cat) = setup_community(&mut h, "restricted").await;
70 + h.add_membership(user_id, comm_id, "moderator").await;
71 +
72 + h.client.get("/p/test/general/new").await;
73 + let resp = h
74 + .client
75 + .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed")
76 + .await;
77 + assert!(resp.status.is_redirection(), "mod should bypass, got {}", resp.status);
78 + }
79 +
80 + // ── Frozen: block all member writes ──
81 +
82 + #[tokio::test]
83 + async fn frozen_blocks_member_reply() {
84 + let mut h = TestHarness::new().await;
85 + let user_id = h.login_as("fmem").await;
86 + let (comm_id, cat_id) = setup_community(&mut h, "active").await;
87 + h.add_membership(user_id, comm_id, "member").await;
88 + let thread_id = h
89 + .create_thread_with_post(cat_id, user_id, "Thread", "OP")
90 + .await;
91 + // Freeze after thread exists
92 + sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
93 + .bind(comm_id)
94 + .execute(&h.db)
95 + .await
96 + .unwrap();
97 +
98 + let url = format!("/p/test/general/{}", thread_id);
99 + h.client.get(&url).await;
100 + let resp = h
101 + .client
102 + .post_form(&format!("{}/reply", url), "body=Reply")
103 + .await;
104 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
105 + assert!(resp.text.contains("frozen"));
106 + }
107 +
108 + #[tokio::test]
109 + async fn frozen_blocks_member_endorsement() {
110 + let mut h = TestHarness::new().await;
111 + let author_id = h.login_as("fauth").await;
112 + let (comm_id, cat_id) = setup_community(&mut h, "active").await;
113 + h.add_membership(author_id, comm_id, "member").await;
114 + let thread_id = h
115 + .create_thread_with_post(cat_id, author_id, "T", "OP")
116 + .await;
117 + let post_id: Uuid =
118 + sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1")
119 + .bind(thread_id)
120 + .fetch_one(&h.db)
121 + .await
122 + .unwrap();
123 +
124 + // Switch to a different user and freeze the community
125 + h.client.post_form("/auth/logout", "").await;
126 + let other_id = h.login_as("fother").await;
127 + h.add_membership(other_id, comm_id, "member").await;
128 + sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
129 + .bind(comm_id)
130 + .execute(&h.db)
131 + .await
132 + .unwrap();
133 +
134 + let url = format!("/p/test/general/{}/posts/{}/endorse", thread_id, post_id);
135 + h.client.get(&format!("/p/test/general/{}", thread_id)).await;
136 + let resp = h.client.post_form(&url, "").await;
137 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
138 + }
139 +
140 + #[tokio::test]
141 + async fn frozen_mod_can_reply() {
142 + let mut h = TestHarness::new().await;
143 + let user_id = h.login_as("fmod").await;
144 + let (comm_id, cat_id) = setup_community(&mut h, "active").await;
145 + h.add_membership(user_id, comm_id, "moderator").await;
146 + let thread_id = h
147 + .create_thread_with_post(cat_id, user_id, "T", "OP")
148 + .await;
149 + sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
150 + .bind(comm_id)
151 + .execute(&h.db)
152 + .await
153 + .unwrap();
154 +
155 + let url = format!("/p/test/general/{}", thread_id);
156 + h.client.get(&url).await;
157 + let resp = h
158 + .client
159 + .post_form(&format!("{}/reply", url), "body=Mod+reply")
160 + .await;
161 + assert!(resp.status.is_redirection(), "mod reply blocked: {}", resp.status);
162 + }
163 +
164 + // ── Superadmin override ──
165 +
166 + #[tokio::test]
167 + async fn superadmin_can_reply_in_frozen_without_role() {
168 + // Superadmin is a platform-level user with no community role here.
169 + let admin_id = Uuid::new_v4();
170 + let mut h = TestHarness::new_with_admin(admin_id).await;
171 +
172 + // Seed a community + author with role, then a thread.
173 + let author_id = h.login_as("sauth").await;
174 + let (comm_id, cat_id) = setup_community(&mut h, "active").await;
175 + h.add_membership(author_id, comm_id, "member").await;
176 + let thread_id = h
177 + .create_thread_with_post(cat_id, author_id, "T", "OP")
178 + .await;
179 + sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
180 + .bind(comm_id)
181 + .execute(&h.db)
182 + .await
183 + .unwrap();
184 +
185 + // Become the superadmin — explicitly no community role.
186 + h.client.post_form("/auth/logout", "").await;
187 + sqlx::query(
188 + "INSERT INTO users (mnw_account_id, username, display_name) \
189 + VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
190 + )
191 + .bind(admin_id)
192 + .execute(&h.db)
193 + .await
194 + .unwrap();
195 + h.client.get("/").await;
196 + h.client
197 + .post_json(
198 + "/_test/login",
199 + &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
200 + .to_string(),
201 + )
202 + .await;
203 +
204 + let url = format!("/p/test/general/{}", thread_id);
205 + h.client.get(&url).await;
206 + let resp = h
207 + .client
208 + .post_form(&format!("{}/reply", url), "body=Super+reply")
209 + .await;
210 + assert!(
211 + resp.status.is_redirection(),
212 + "superadmin reply blocked: status={} body={}",
213 + resp.status,
214 + resp.text
215 + );
216 + }
217 +
218 + // ── Archived: same as frozen + hidden from default listing ──
219 +
220 + #[tokio::test]
221 + async fn archived_blocks_member_reply() {
222 + let mut h = TestHarness::new().await;
223 + let user_id = h.login_as("amem").await;
224 + let (comm_id, cat_id) = setup_community(&mut h, "active").await;
225 + h.add_membership(user_id, comm_id, "member").await;
226 + let thread_id = h
227 + .create_thread_with_post(cat_id, user_id, "T", "OP")
228 + .await;
229 + sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
230 + .bind(comm_id)
231 + .execute(&h.db)
232 + .await
233 + .unwrap();
234 +
235 + let url = format!("/p/test/general/{}", thread_id);
236 + h.client.get(&url).await;
237 + let resp = h
238 + .client
239 + .post_form(&format!("{}/reply", url), "body=No")
240 + .await;
241 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
242 + assert!(resp.text.contains("archived"));
243 + }
244 +
245 + #[tokio::test]
246 + async fn archived_excluded_from_default_listing() {
247 + let mut h = TestHarness::new().await;
248 + h.create_community("Active", "active-comm").await;
249 + let arch_id = h.create_community("Archived", "arch-comm").await;
250 + sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
251 + .bind(arch_id)
252 + .execute(&h.db)
253 + .await
254 + .unwrap();
255 +
256 + let resp = h.client.get("/").await;
257 + assert!(resp.text.contains("Active"));
258 + assert!(
259 + !resp.text.contains("/p/arch-comm"),
260 + "archived community should not appear in default listing"
261 + );
262 +
263 + let resp = h.client.get("/?filter=archived").await;
264 + assert!(resp.text.contains("/p/arch-comm"), "archived view should show it");
265 + assert!(
266 + !resp.text.contains("/p/active-comm"),
267 + "archived view should not include active communities"
268 + );
269 + }
270 +
271 + // ── State-change route ──
272 +
273 + #[tokio::test]
274 + async fn owner_can_change_state() {
275 + let mut h = TestHarness::new().await;
276 + let user_id = h.login_as("owner").await;
277 + let (comm_id, _cat) = setup_community(&mut h, "active").await;
278 + h.add_membership(user_id, comm_id, "owner").await;
279 +
280 + // Prime CSRF + cookie
281 + h.client.get("/p/test/settings").await;
282 + let resp = h
283 + .client
284 + .post_form("/p/test/settings/state", "state=frozen")
285 + .await;
286 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
287 +
288 + let state: String =
289 + sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
290 + .bind(comm_id)
291 + .fetch_one(&h.db)
292 + .await
293 + .unwrap();
294 + assert_eq!(state, "frozen");
295 + }
296 +
297 + #[tokio::test]
298 + async fn superadmin_can_change_state_without_role() {
299 + let admin_id = Uuid::new_v4();
300 + let mut h = TestHarness::new_with_admin(admin_id).await;
301 + let (comm_id, _cat) = setup_community(&mut h, "active").await;
302 + sqlx::query(
303 + "INSERT INTO users (mnw_account_id, username, display_name) \
304 + VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
305 + )
306 + .bind(admin_id)
307 + .execute(&h.db)
308 + .await
309 + .unwrap();
310 + h.client.get("/").await;
311 + h.client
312 + .post_json(
313 + "/_test/login",
314 + &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
315 + .to_string(),
316 + )
317 + .await;
318 +
319 + h.client.get("/p/test/settings").await;
320 + let resp = h
321 + .client
322 + .post_form("/p/test/settings/state", "state=archived")
323 + .await;
324 + assert!(resp.status.is_redirection(), "status: {}", resp.status);
325 +
326 + let state: String =
327 + sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
328 + .bind(comm_id)
329 + .fetch_one(&h.db)
330 + .await
331 + .unwrap();
332 + assert_eq!(state, "archived");
333 + }
334 +
335 + #[tokio::test]
336 + async fn member_cannot_change_state() {
337 + let mut h = TestHarness::new().await;
338 + let user_id = h.login_as("nope").await;
339 + let (comm_id, _cat) = setup_community(&mut h, "active").await;
340 + h.add_membership(user_id, comm_id, "member").await;
341 +
342 + h.client.get("/p/test/settings").await;
343 + let resp = h
344 + .client
345 + .post_form("/p/test/settings/state", "state=frozen")
346 + .await;
347 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
348 +
349 + let state: String =
350 + sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
351 + .bind(comm_id)
352 + .fetch_one(&h.db)
353 + .await
354 + .unwrap();
355 + assert_eq!(state, "active", "state should be unchanged");
356 + }
357 +
358 + #[tokio::test]
359 + async fn state_change_rejects_unknown_value() {
360 + let mut h = TestHarness::new().await;
361 + let user_id = h.login_as("ownerbad").await;
362 + let (comm_id, _cat) = setup_community(&mut h, "active").await;
363 + h.add_membership(user_id, comm_id, "owner").await;
364 +
365 + h.client.get("/p/test/settings").await;
366 + let resp = h
367 + .client
368 + .post_form("/p/test/settings/state", "state=bogus")
369 + .await;
370 + assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
371 +
372 + let state: String =
373 + sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
374 + .bind(comm_id)
375 + .fetch_one(&h.db)
376 + .await
377 + .unwrap();
378 + assert_eq!(state, "active", "state should be unchanged on validation error");
379 + }
@@ -0,0 +1,66 @@
1 + # Forum moderation policy
2 +
3 + How MNW handles communities that drift into neglect or hostility. This is the public version of the policy; the working internal version lives in the Multithreaded source tree.
4 +
5 + ## Principle
6 +
7 + MNW communities are creator-moderated. We are not the content police. But we do enforce a quality floor: communities that stop being maintained will be addressed, because neglected spaces harm fans and reflect on the platform.
8 +
9 + The goal is always to give the creator a real path forward, not to punish.
10 +
11 + ## State ladder
12 +
13 + Every community sits in one of four states. State changes are reversible — a community can move freely between them. The audit log records every transition with the actor and timestamp.
14 +
15 + ### Active
16 +
17 + The default. Members can start threads, reply, and use forum features as their Fan+ status allows.
18 +
19 + ### Restricted
20 +
21 + New thread creation is disabled for non-moderator accounts. Existing threads still accept replies. Mods and platform admin keep full access.
22 +
23 + Typical trigger: unresolved flags older than ~14 days, or a pattern of reports going unaddressed.
24 +
25 + ### Frozen
26 +
27 + Read-only for everyone except moderators performing moderation actions. Members cannot post, reply, footnote, or endorse. Mods can clear the backlog, ban bad actors, and remove posts — once the queue is addressed, they unfreeze.
28 +
29 + Typical trigger: continued inaction after a Restricted period.
30 +
31 + ### Archived
32 +
33 + Behaves like Frozen and is hidden from the default forum directory. Still reachable by direct URL. Surfaces under the explicit "View archived" filter.
34 +
35 + Reactivation is just a state change back to Active.
36 +
37 + ## Clean slate
38 +
39 + A creator (or platform admin acting on the creator's behalf) can request a clean slate: every thread, post, footnote, endorsement, and flag is deleted. The community row, categories, settings, members, bans, and mutes are preserved.
40 +
41 + After the wipe, a single pinned, locked notice is posted in the first category recording who reset the forum and when. No explanation. No blame. No platform statement.
42 +
43 + The clean slate is irreversible at the data level, so the admin UI requires typing the community slug to confirm — the same pattern GitHub uses for repository deletion.
44 +
45 + The creator's account, project, tier, payment history, and fan relationships are untouched by a clean slate.
46 +
47 + ## What MNW never does
48 +
49 + - Public announcement explaining why a community was reset or moved between states.
50 + - Marking the creator's profile, project page, or storefront with the moderation history.
51 + - Discussing the situation with other creators or fans.
52 + - Using forum moderation history as a factor in unrelated account decisions.
53 + - Naming or shaming in any context.
54 +
55 + ## Authorization
56 +
57 + State changes and clean slates require either:
58 +
59 + - A community **Owner** or **Moderator** acting within that community, or
60 + - The **platform admin** acting from the dedicated admin view.
61 +
62 + A robust per-permission system is on the roadmap; for now, this two-layer model is what the Multithreaded forum software enforces.
63 +
64 + ## Reporting a problem
65 +
66 + If you encounter content or behavior that violates a community's rules, use the flag button on the post. Flags route to that community's moderators first. If the community's mods are non-responsive, MNW staff review unresolved flags as part of platform-level monitoring.