Skip to main content

max / multithreaded

v0.3.0: Beta-ready milestone — atomic auto-hide flagging, moderation improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 02:58 UTC
Commit: 8565d36a6b080832822fbce3b056af8271284127
Parent: cc8efbd
7 files changed, +48 insertions, -44 deletions
M Cargo.lock +3 -3
@@ -2060,14 +2060,14 @@ dependencies = [
2060 2060
2061 2061 [[package]]
2062 2062 name = "mt-core"
2063 - version = "0.2.4"
2063 + version = "0.2.5"
2064 2064 dependencies = [
2065 2065 "chrono",
2066 2066 ]
2067 2067
2068 2068 [[package]]
2069 2069 name = "mt-db"
2070 - version = "0.2.4"
2070 + version = "0.2.5"
2071 2071 dependencies = [
2072 2072 "chrono",
2073 2073 "serde",
@@ -2095,7 +2095,7 @@ dependencies = [
2095 2095
2096 2096 [[package]]
2097 2097 name = "multithreaded"
2098 - version = "0.2.4"
2098 + version = "0.2.5"
2099 2099 dependencies = [
2100 2100 "ammonia",
2101 2101 "askama",
M Cargo.toml +1 -1
@@ -7,7 +7,7 @@ members = [
7 7 default-members = ["."]
8 8
9 9 [workspace.package]
10 - version = "0.2.5"
10 + version = "0.3.0"
11 11 edition = "2024"
12 12 license-file = "LICENSE"
13 13
@@ -97,21 +97,45 @@ pub async fn insert_footnote(
97 97 }
98 98
99 99 /// Mod-remove a post: set removed_by/removed_at. Content stays intact for audit.
100 + /// Returns true if the post was actually removed (false if already removed).
100 101 #[tracing::instrument(skip_all)]
101 102 pub async fn mod_remove_post(
102 103 pool: &PgPool,
103 104 post_id: Uuid,
104 105 removed_by_id: Uuid,
105 - ) -> Result<(), sqlx::Error> {
106 - sqlx::query(
106 + ) -> Result<bool, sqlx::Error> {
107 + let result = sqlx::query(
107 108 "UPDATE posts SET removed_by = $2, removed_at = now()
108 - WHERE id = $1",
109 + WHERE id = $1 AND removed_at IS NULL",
109 110 )
110 111 .bind(post_id)
111 112 .bind(removed_by_id)
112 113 .execute(pool)
113 114 .await?;
114 - Ok(())
115 + Ok(result.rows_affected() > 0)
116 + }
117 +
118 + /// Atomically auto-hide a post if pending flag count meets the threshold.
119 + /// Combines count check and removal in a single query to avoid race conditions.
120 + /// Returns true if the post was actually removed.
121 + #[tracing::instrument(skip_all)]
122 + pub async fn auto_hide_if_threshold_met(
123 + pool: &PgPool,
124 + post_id: Uuid,
125 + removed_by_id: Uuid,
126 + threshold: i32,
127 + ) -> Result<bool, sqlx::Error> {
128 + let result = sqlx::query(
129 + "UPDATE posts SET removed_by = $2, removed_at = now()
130 + WHERE id = $1 AND removed_at IS NULL
131 + AND (SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND resolved_at IS NULL) >= $3",
132 + )
133 + .bind(post_id)
134 + .bind(removed_by_id)
135 + .bind(threshold as i64)
136 + .execute(pool)
137 + .await?;
138 + Ok(result.rows_affected() > 0)
115 139 }
116 140
117 141 /// Update a thread's title.
@@ -1256,19 +1256,6 @@ pub async fn has_user_flagged_post(
1256 1256 Ok(count > 0)
1257 1257 }
1258 1258
1259 - /// Count unresolved (pending) flags on a post.
1260 - #[tracing::instrument(skip_all)]
1261 - pub async fn count_pending_flags_for_post(
1262 - pool: &PgPool,
1263 - post_id: Uuid,
1264 - ) -> Result<i64, sqlx::Error> {
1265 - sqlx::query_scalar(
1266 - "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND resolved_at IS NULL",
1267 - )
1268 - .bind(post_id)
1269 - .fetch_one(pool)
1270 - .await
1271 - }
1272 1259
1273 1260 // ============================================================================
1274 1261 // Endorsement queries
@@ -66,21 +66,21 @@ pub(super) async fn flag_post_handler(
66 66 StatusCode::INTERNAL_SERVER_ERROR.into_response()
67 67 })?;
68 68
69 - // Auto-hide: if community has a threshold and pending flags meet it, remove the post
69 + // Auto-hide: atomically check flag count and remove post if threshold met
70 70 if let Some(threshold) = community.auto_hide_threshold
71 71 && threshold > 0
72 72 {
73 - let pending = mt_db::queries::count_pending_flags_for_post(&state.db, post_id)
74 - .await
75 - .unwrap_or(0);
76 - if pending >= threshold as i64 {
77 - if let Err(e) = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id).await {
78 - tracing::error!(error = ?e, "auto-hide: failed to remove post");
73 + match mt_db::mutations::auto_hide_if_threshold_met(
74 + &state.db, post_id, user.user_id, threshold,
75 + ).await {
76 + Ok(true) => {
77 + log_mod_action(
78 + &state.db, Some(community.id), user.user_id,
79 + "auto_hide_post", Some(post_data.author_id), Some(post_id), None,
80 + ).await;
79 81 }
80 - log_mod_action(
81 - &state.db, Some(community.id), user.user_id,
82 - "auto_hide_post", Some(post_data.author_id), Some(post_id), None,
83 - ).await;
82 + Ok(false) => {} // threshold not met or already removed
83 + Err(e) => tracing::error!(error = ?e, "auto-hide: failed to check/remove post"),
84 84 }
85 85 }
86 86
@@ -143,8 +143,8 @@ pub(super) async fn remove_flagged_post_handler(
143 143 let (post_id, author_id) = flag_row
144 144 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
145 145
146 - // Mod-remove the post
147 - mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
146 + // Mod-remove the post (idempotent — returns false if already removed)
147 + let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
148 148 .await
149 149 .map_err(|e| {
150 150 tracing::error!(error = ?e, "db error removing flagged post");
@@ -119,7 +119,7 @@ pub(super) async fn mod_remove_post_handler(
119 119 return Err(StatusCode::FORBIDDEN.into_response());
120 120 }
121 121
122 - mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
122 + let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
123 123 .await
124 124 .map_err(|e| {
125 125 tracing::error!(error = ?e, "db error removing post");
M todo.md +2 -9
@@ -1,19 +1,12 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.2.4. Audit grade: A. Deployed to hetzner (forums.makenot.work). All remaining items from completed phases resolved. MNW Forums tab integration live.
3 + Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.2.5. Audit grade: A. Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set).
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6
7 7 Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse project forums, create threads, post replies, and moderators can pin/lock/ban/mute. No live chat, no E2E encryption, no federation — those are post-alpha.
8 8
9 - ---
10 -
11 - ## Pre-Beta
12 -
13 - - [ ] Deploy latest to hetzner+astra (v0.2.5 — includes phases 23-24, auto-hide, directory pagination, moderation tests)
14 - - [ ] Set `MT_BASE_URL=https://forums.makenot.work` in MNW production env (enables Forums dashboard tab)
15 - - [ ] Run migration 020 (auto_hide_threshold) on production DB
16 - - [ ] Configure S3 env vars on production (enables image uploads)
9 + No remaining pre-beta items. Only deferred post-beta items below.
17 10
18 11 ---
19 12