max / multithreaded
7 files changed,
+48 insertions,
-44 deletions
| @@ -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", |
| @@ -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"); |
| @@ -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 |