Skip to main content

max / makenotwork

mt: bind every mod-log write to its mutation's transaction (ultra-fuzz Run #5 S3 + M-DI1) Auditable moderation actions now write their mod_log row on the same transaction as the mutation, so an action can never commit without its correctly-attributed audit row. Adds ModActor{System,User} + migration 032 (nullable mod_log.actor_id); makes insert_mod_log and the auditable mutations executor-generic; routes all 23 log sites through begin_tx/audit/commit_tx and deletes the fire-and-forget log_mod_action. Flag-threshold auto-hide is logged as System (NULL actor_id), not the member who tripped it. Also: gate the dead non-cascade mod_remove_post behind mt-db's test-support feature (unreachable from production handlers), and filter mod-removed posts / soft-deleted threads out of quote verification.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 05:52 UTC
Signed with PGP, not checked
Commit: f624d4a36379d8f317915300f08686f0e10f323f
Parent: 5a51b7a
15 files changed, +427 insertions, -228 deletions
@@ -98,3 +98,6 @@
98 98 http-body-util = "0.1"
99 99 wiremock = "0.6"
100 100 pom-contract = { path = "../shared/pom-contract" }
101 + # Enable mt-db's setup-only mutations for the integration suite. Resolver 2 keeps
102 + # this feature out of the normal/production build.
103 + mt-db = { workspace = true, features = ["test-support"] }
@@ -3,6 +3,11 @@
3 3 version.workspace = true
4 4 edition.workspace = true
5 5
6 + [features]
7 + # Exposes setup-only mutations (e.g. `mod_remove_post`) used by the integration
8 + # test suite. Off in normal/production builds so handler code cannot call them.
9 + test-support = []
10 +
6 11 [dependencies]
7 12 mt-core = { workspace = true }
8 13 sqlx = { workspace = true }
@@ -13,10 +13,10 @@
13 13 use crate::templates::*;
14 14 use crate::AppState;
15 15
16 - use mt_core::types::ModAction;
16 + use mt_core::types::{ModAction, ModActor};
17 17
18 18 use super::{
19 - get_community, log_mod_action, parse_uuid, template_user, AdminSearchQuery,
19 + audit, begin_tx, commit_tx, get_community, parse_uuid, template_user, AdminSearchQuery,
20 20 CleanSlateForm, SuspendForm,
21 21 };
22 22
@@ -89,17 +89,18 @@
89 89 let community_id = parse_uuid(&id)?;
90 90 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
91 91
92 - mt_db::mutations::suspend_community(&state.db, community_id, reason)
92 + let mut tx = begin_tx(&state.db).await?;
93 + mt_db::mutations::suspend_community(&mut *tx, community_id, reason)
93 94 .await
94 95 .map_err(|e| {
95 96 tracing::error!(error = ?e, "db error suspending community");
96 97 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
97 98 })?;
98 -
99 - log_mod_action(
100 - &state.db, None, admin.user_id,
99 + audit(
100 + &mut tx, None, ModActor::User(admin.user_id),
101 101 ModAction::SuspendCommunity, None, Some(community_id), reason,
102 - ).await;
102 + ).await?;
103 + commit_tx(tx).await?;
103 104
104 105 Ok(Redirect::to("/_admin?toast=Community+suspended"))
105 106 }
@@ -112,17 +113,18 @@
112 113 ) -> Result<Redirect, Response> {
113 114 let community_id = parse_uuid(&id)?;
114 115
115 - mt_db::mutations::unsuspend_community(&state.db, community_id)
116 + let mut tx = begin_tx(&state.db).await?;
117 + mt_db::mutations::unsuspend_community(&mut *tx, community_id)
116 118 .await
117 119 .map_err(|e| {
118 120 tracing::error!(error = ?e, "db error unsuspending community");
119 121 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
120 122 })?;
121 -
122 - log_mod_action(
123 - &state.db, None, admin.user_id,
123 + audit(
124 + &mut tx, None, ModActor::User(admin.user_id),
124 125 ModAction::UnsuspendCommunity, None, Some(community_id), None,
125 - ).await;
126 + ).await?;
127 + commit_tx(tx).await?;
126 128
127 129 Ok(Redirect::to("/_admin?toast=Community+unsuspended"))
128 130 }
@@ -137,17 +139,18 @@
137 139 let user_id = parse_uuid(&id)?;
138 140 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
139 141
140 - mt_db::mutations::suspend_user(&state.db, user_id, reason)
142 + let mut tx = begin_tx(&state.db).await?;
143 + mt_db::mutations::suspend_user(&mut *tx, user_id, reason)
141 144 .await
142 145 .map_err(|e| {
143 146 tracing::error!(error = ?e, "db error suspending user");
144 147 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
145 148 })?;
146 -
147 - log_mod_action(
148 - &state.db, None, admin.user_id,
149 + audit(
150 + &mut tx, None, ModActor::User(admin.user_id),
149 151 ModAction::SuspendUser, Some(user_id), None, reason,
150 - ).await;
152 + ).await?;
153 + commit_tx(tx).await?;
151 154
152 155 Ok(Redirect::to("/_admin?toast=User+suspended"))
153 156 }
@@ -160,17 +163,18 @@
160 163 ) -> Result<Redirect, Response> {
161 164 let user_id = parse_uuid(&id)?;
162 165
163 - mt_db::mutations::unsuspend_user(&state.db, user_id)
166 + let mut tx = begin_tx(&state.db).await?;
167 + mt_db::mutations::unsuspend_user(&mut *tx, user_id)
164 168 .await
165 169 .map_err(|e| {
166 170 tracing::error!(error = ?e, "db error unsuspending user");
167 171 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
168 172 })?;
169 -
170 - log_mod_action(
171 - &state.db, None, admin.user_id,
173 + audit(
174 + &mut tx, None, ModActor::User(admin.user_id),
172 175 ModAction::UnsuspendUser, Some(user_id), None, None,
173 - ).await;
176 + ).await?;
177 + commit_tx(tx).await?;
174 178
175 179 Ok(Redirect::to("/_admin?toast=User+unsuspended"))
176 180 }
@@ -253,8 +257,9 @@
253 257 .into_response());
254 258 }
255 259
260 + let mut tx = begin_tx(&state.db).await?;
256 261 let result = mt_db::mutations::clean_slate_community(
257 - &state.db,
262 + &mut tx,
258 263 community.id,
259 264 admin.user_id,
260 265 &admin.username,
@@ -264,17 +269,17 @@
264 269 tracing::error!(error = ?e, "clean-slate failed");
265 270 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
266 271 })?;
267 -
268 - log_mod_action(
269 - &state.db,
272 + audit(
273 + &mut tx,
270 274 Some(community.id),
271 - admin.user_id,
275 + ModActor::User(admin.user_id),
272 276 ModAction::CleanSlateCommunity,
273 277 None,
274 278 result.system_thread_id,
275 279 Some(&format!("deleted {} threads", result.deleted_thread_count)),
276 280 )
277 - .await;
281 + .await?;
282 + commit_tx(tx).await?;
278 283
279 284 Ok(Redirect::to(&format!(
280 285 "/_admin/communities/{slug}?toast=Community+reset"
@@ -11,10 +11,10 @@
11 11 use crate::auth::MaybeUser;
12 12 use crate::AppState;
13 13
14 - use mt_core::types::ModAction;
14 + use mt_core::types::{ModAction, ModActor};
15 15
16 16 use super::{
17 - check_community_access, field_error, get_community, log_mod_action, parse_uuid,
17 + audit, begin_tx, check_community_access, commit_tx, field_error, get_community, parse_uuid,
18 18 require_mod_or_owner,
19 19 };
20 20
@@ -81,21 +81,32 @@
81 81 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
82 82 })?;
83 83
84 - // Auto-hide: atomically check flag count and remove post if threshold met
84 + // Auto-hide: atomically check flag count and remove post if threshold met.
85 + // The removal and its audit row commit on one transaction, so the auto-hide
86 + // can never land without its log entry — and the log records `System` as the
87 + // actor (NULL actor_id), not the member who happened to trip the threshold.
88 + // Best-effort: a failure here is traced but does not fail the flag submission
89 + // (the flag itself already committed; the post will re-trip on the next flag).
85 90 if let Some(threshold) = community.auto_hide_threshold
86 91 && threshold > 0
87 92 {
88 - match mt_db::mutations::auto_hide_if_threshold_met(
89 - &state.db, post_id, threshold,
90 - ).await {
91 - Ok(true) => {
92 - log_mod_action(
93 - &state.db, Some(community.id), user.user_id,
93 + let hide = async {
94 + let mut tx = state.db.begin().await?;
95 + let hidden =
96 + mt_db::mutations::auto_hide_if_threshold_met(&mut *tx, post_id, threshold).await?;
97 + if hidden {
98 + mt_db::mutations::insert_mod_log(
99 + &mut *tx, Some(community.id), ModActor::System,
94 100 ModAction::AutoHidePost, Some(post_data.author_id), Some(post_id), None,
95 - ).await;
101 + )
102 + .await?;
96 103 }
97 - Ok(false) => {} // threshold not met or already removed
98 - Err(e) => tracing::error!(error = ?e, "auto-hide: failed to check/remove post"),
104 + tx.commit().await?;
105 + Ok::<(), sqlx::Error>(())
106 + }
107 + .await;
108 + if let Err(e) = hide {
109 + tracing::error!(error = ?e, "auto-hide: failed to hide/log post");
99 110 }
100 111 }
101 112
@@ -175,33 +186,39 @@
175 186 let (post_id, author_id, thread_id) = flag_row
176 187 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
177 188
178 - // Mod-remove the post (idempotent); if it is the OP, the whole thread is
179 - // soft-deleted in the same transaction.
180 - let removal = mt_db::mutations::mod_remove_post_cascade(&state.db, post_id, user.user_id)
189 + // Mod-remove the post, resolve its flags, and write the audit row(s) on one
190 + // transaction: the removal, the flag resolution, and the log all commit
191 + // together or not at all. If the post is the OP, the whole thread is
192 + // soft-deleted and that deletion is logged on the same tx.
193 + let mut tx = begin_tx(&state.db).await?;
194 + let removal = mt_db::mutations::mod_remove_post_cascade(&mut tx, post_id, user.user_id)
181 195 .await
182 196 .map_err(|e| {
183 197 tracing::error!(error = ?e, "db error removing flagged post");
184 198 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
185 199 })?;
186 200
187 - // Resolve all flags on this post
188 - mt_db::mutations::resolve_all_flags_for_post(&state.db, post_id, user.user_id, "removed")
201 + mt_db::mutations::resolve_all_flags_for_post(&mut *tx, post_id, user.user_id, "removed")
189 202 .await
190 203 .map_err(|e| {
191 204 tracing::error!(error = ?e, "db error resolving flags");
192 205 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
193 206 })?;
194 207
195 - log_mod_action(
196 - &state.db, Some(community.id), user.user_id,
208 + audit(
209 + &mut tx, Some(community.id), ModActor::User(user.user_id),
197 210 ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None,
198 - ).await;
211 + ).await?;
199 212
200 213 if removal.thread_removed {
201 - log_mod_action(
202 - &state.db, Some(community.id), user.user_id,
214 + audit(
215 + &mut tx, Some(community.id), ModActor::User(user.user_id),
203 216 ModAction::DeleteThread, Some(author_id), Some(thread_id), None,
204 - ).await;
217 + ).await?;
218 + }
219 + commit_tx(tx).await?;
220 +
221 + if removal.thread_removed {
205 222 return Ok(Redirect::to(&format!(
206 223 "/p/{slug}/moderation?toast=Thread+removed"
207 224 )));
@@ -7,7 +7,7 @@
7 7 use chrono::{DateTime, Duration, Utc};
8 8 use uuid::Uuid;
9 9
10 - use mt_core::types::{CommunityRole, CommunityState, ModAction};
10 + use mt_core::types::{CommunityRole, CommunityState, ModAction, ModActor};
11 11
12 12 use crate::auth;
13 13 use crate::templates::*;
@@ -155,23 +155,53 @@
155 155 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
156 156 }
157 157
158 - /// Fire-and-forget mod log entry. Logs errors but never fails the request.
159 - pub(crate) async fn log_mod_action(
158 + /// Begin a transaction, mapping a DB error to a branded 500.
159 + #[allow(clippy::result_large_err)]
160 + pub(crate) async fn begin_tx(
160 161 db: &sqlx::PgPool,
162 + ) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, Response> {
163 + db.begin().await.map_err(|e| {
164 + tracing::error!(error = ?e, "db error opening transaction");
165 + crate::error_page::internal_error()
166 + })
167 + }
168 +
169 + /// Commit a transaction, mapping a DB error to a branded 500.
170 + #[allow(clippy::result_large_err)]
171 + pub(crate) async fn commit_tx(
172 + tx: sqlx::Transaction<'_, sqlx::Postgres>,
173 + ) -> Result<(), Response> {
174 + tx.commit().await.map_err(|e| {
175 + tracing::error!(error = ?e, "db error committing transaction");
176 + crate::error_page::internal_error()
177 + })
178 + }
179 +
180 + /// Write a mod-log entry on the caller's transaction.
181 + ///
182 + /// The audit row is written on the *same* `tx` as the mutation it records, so
183 + /// the moderation action and its log either both commit or both roll back — an
184 + /// auditable action can never land without its correctly-attributed audit row
185 + /// (closes the fire-and-forget gap). A `System` actor (e.g. flag-threshold
186 + /// auto-hide) is recorded as a NULL `actor_id`, never the user who tripped it.
187 + #[allow(clippy::result_large_err)]
188 + pub(crate) async fn audit(
189 + tx: &mut sqlx::PgConnection,
161 190 community_id: Option<Uuid>,
162 - actor_id: Uuid,
191 + actor: ModActor,
163 192 action: ModAction,
164 193 target_user: Option<Uuid>,
165 194 target_id: Option<Uuid>,
166 195 reason: Option<&str>,
167 - ) {
168 - if let Err(e) = mt_db::mutations::insert_mod_log(
169 - db, community_id, actor_id, action, target_user, target_id, reason,
196 + ) -> Result<(), Response> {
197 + mt_db::mutations::insert_mod_log(
198 + &mut *tx, community_id, actor, action, target_user, target_id, reason,
170 199 )
171 200 .await
172 - {
201 + .map_err(|e| {
173 202 tracing::error!(error = %e, "failed to insert mod log");
174 - }
203 + crate::error_page::internal_error()
204 + })
175 205 }
176 206
177 207 /// Convert a session user to a template session user.
@@ -16,10 +16,11 @@
16 16 use mt_core::types::{BanType, ModAction};
17 17
18 18 use super::{
19 - field_error, get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner,
20 - log_mod_action, parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm,
21 - PageQuery, UnbanForm,
19 + audit, begin_tx, commit_tx, field_error, get_role, get_thread, get_user_by_username,
20 + is_mod_or_owner, is_owner, parse_duration, parse_uuid, require_mod_or_owner, template_user,
21 + BanForm, PageQuery, UnbanForm,
22 22 };
23 + use mt_core::types::ModActor;
23 24
24 25 #[tracing::instrument(skip_all)]
25 26 pub(super) async fn pin_thread_handler(
@@ -38,18 +39,20 @@
38 39 }
39 40
40 41 let new_pinned = !thread_data.pinned;
41 - mt_db::mutations::set_thread_pinned(&state.db, thread_data.id, new_pinned)
42 + let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread };
43 +
44 + let mut tx = begin_tx(&state.db).await?;
45 + mt_db::mutations::set_thread_pinned(&mut *tx, thread_data.id, new_pinned)
42 46 .await
43 47 .map_err(|e| {
44 48 tracing::error!(error = ?e, "db error toggling pin");
45 49 StatusCode::INTERNAL_SERVER_ERROR.into_response()
46 50 })?;
47 -
48 - let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread };
49 - log_mod_action(
50 - &state.db, Some(thread_data.community_id), user.user_id,
51 + audit(
52 + &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id),
51 53 action, None, Some(thread_data.id), None,
52 - ).await;
54 + ).await?;
55 + commit_tx(tx).await?;
53 56
54 57 let toast = if new_pinned { "Thread+pinned" } else { "Thread+unpinned" };
55 58 Ok(Redirect::to(&format!(
@@ -74,18 +77,20 @@
74 77 }
75 78
76 79 let new_locked = !thread_data.locked;
77 - mt_db::mutations::set_thread_locked(&state.db, thread_data.id, new_locked)
80 + let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread };
81 +
82 + let mut tx = begin_tx(&state.db).await?;
83 + mt_db::mutations::set_thread_locked(&mut *tx, thread_data.id, new_locked)
78 84 .await
79 85 .map_err(|e| {
80 86 tracing::error!(error = ?e, "db error toggling lock");
81 87 StatusCode::INTERNAL_SERVER_ERROR.into_response()
82 88 })?;
83 -
84 - let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread };
85 - log_mod_action(
86 - &state.db, Some(thread_data.community_id), user.user_id,
89 + audit(
90 + &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id),
87 91 action, None, Some(thread_data.id), None,
88 - ).await;
92 + ).await?;
93 + commit_tx(tx).await?;
89 94
90 95 let toast = if new_locked { "Thread+locked" } else { "Thread+unlocked" };
91 96 Ok(Redirect::to(&format!(
@@ -122,26 +127,35 @@
122 127 return Err(StatusCode::FORBIDDEN.into_response());
123 128 }
124 129
125 - let removal = mt_db::mutations::mod_remove_post_cascade(&state.db, post_id, user.user_id)
130 + // Resolve the thread id up front so the cascade-delete log can be written on
131 + // the same transaction as the removal.
132 + let thread_id = parse_uuid(&thread_id_str)?;
133 +
134 + let mut tx = begin_tx(&state.db).await?;
135 + let removal = mt_db::mutations::mod_remove_post_cascade(&mut tx, post_id, user.user_id)
126 136 .await
127 137 .map_err(|e| {
128 138 tracing::error!(error = ?e, "db error removing post");
129 139 StatusCode::INTERNAL_SERVER_ERROR.into_response()
130 140 })?;
131 141
132 - log_mod_action(
133 - &state.db, Some(post_data.community_id), user.user_id,
142 + audit(
143 + &mut tx, Some(post_data.community_id), ModActor::User(user.user_id),
134 144 ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None,
135 - ).await;
145 + ).await?;
136 146
137 - // Removing the opening post cascades to soft-deleting the whole thread; the
138 - // thread page now 404s, so send the mod back to the category listing.
147 + // Removing the opening post cascades to soft-deleting the whole thread; log
148 + // the thread deletion on the same tx so the two records commit together.
139 149 if removal.thread_removed {
140 - let thread_id = parse_uuid(&thread_id_str)?;
141 - log_mod_action(
142 - &state.db, Some(post_data.community_id), user.user_id,
150 + audit(
151 + &mut tx, Some(post_data.community_id), ModActor::User(user.user_id),
143 152 ModAction::DeleteThread, Some(post_data.author_id), Some(thread_id), None,
144 - ).await;
153 + ).await?;
154 + }
155 + commit_tx(tx).await?;
156 +
157 + // The thread page now 404s, so send the mod back to the category listing.
158 + if removal.thread_removed {
145 159 return Ok(Redirect::to(&format!(
146 160 "/p/{slug}/{category_slug}?toast=Thread+removed"
147 161 )));
@@ -278,8 +292,9 @@
278 292 return Err(field_error("reason", "Reason too long (max 1024 bytes)."));
279 293 }
280 294
295 + let mut tx = begin_tx(&state.db).await?;
281 296 mt_db::mutations::create_community_ban(
282 - &state.db, community.id, target_id, user.user_id,
297 + &mut *tx, community.id, target_id, user.user_id,
283 298 BanType::Ban, reason, expires_at,
284 299 )
285 300 .await
@@ -287,11 +302,11 @@
287 302 tracing::error!(error = ?e, "db error creating ban");
288 303 StatusCode::INTERNAL_SERVER_ERROR.into_response()
289 304 })?;
290 -
291 - log_mod_action(
292 - &state.db, Some(community.id), user.user_id,
305 + audit(
306 + &mut tx, Some(community.id), ModActor::User(user.user_id),
293 307 ModAction::Ban, Some(target_id), None, reason,
294 - ).await;
308 + ).await?;
309 + commit_tx(tx).await?;
295 310
296 311 Ok(Redirect::to(&format!(
297 312 "/p/{slug}/moderation?toast=User+banned"
@@ -312,17 +327,18 @@
312 327
313 328 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
314 329
315 - mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Ban)
330 + let mut tx = begin_tx(&state.db).await?;
331 + mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Ban)
316 332 .await
317 333 .map_err(|e| {
318 334 tracing::error!(error = ?e, "db error removing ban");
319 335 StatusCode::INTERNAL_SERVER_ERROR.into_response()
320 336 })?;
321 -
322 - log_mod_action(
323 - &state.db, Some(community.id), user.user_id,
337 + audit(
338 + &mut tx, Some(community.id), ModActor::User(user.user_id),
324 339 ModAction::Unban, Some(target_id), None, None,
325 - ).await;
340 + ).await?;
341 + commit_tx(tx).await?;
326 342
327 343 Ok(Redirect::to(&format!(
328 344 "/p/{slug}/moderation?toast=User+unbanned"
@@ -363,8 +379,9 @@
363 379 return Err(field_error("reason", "Reason too long (max 1024 bytes)."));
364 380 }
365 381
382 + let mut tx = begin_tx(&state.db).await?;
366 383 mt_db::mutations::create_community_ban(
367 - &state.db, community.id, target_id, user.user_id,
384 + &mut *tx, community.id, target_id, user.user_id,
368 385 BanType::Mute, reason, expires_at,
369 386 )
370 387 .await
@@ -372,11 +389,11 @@
372 389 tracing::error!(error = ?e, "db error creating mute");
373 390 StatusCode::INTERNAL_SERVER_ERROR.into_response()
374 391 })?;
375 -
376 - log_mod_action(
377 - &state.db, Some(community.id), user.user_id,
392 + audit(
393 + &mut tx, Some(community.id), ModActor::User(user.user_id),
378 394 ModAction::Mute, Some(target_id), None, reason,
379 - ).await;
395 + ).await?;
396 + commit_tx(tx).await?;
380 397
381 398 Ok(Redirect::to(&format!(
382 399 "/p/{slug}/moderation?toast=User+muted"
@@ -397,17 +414,18 @@
397 414
398 415 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
399 416
400 - mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Mute)
417 + let mut tx = begin_tx(&state.db).await?;
418 + mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Mute)
401 419 .await
402 420 .map_err(|e| {
403 421 tracing::error!(error = ?e, "db error removing mute");
404 422 StatusCode::INTERNAL_SERVER_ERROR.into_response()
405 423 })?;
406 -
407 - log_mod_action(
408 - &state.db, Some(community.id), user.user_id,
424 + audit(
425 + &mut tx, Some(community.id), ModActor::User(user.user_id),
409 426 ModAction::Unmute, Some(target_id), None, None,
410 - ).await;
427 + ).await?;
428 + commit_tx(tx).await?;
411 429
412 430 Ok(Redirect::to(&format!(
413 431 "/p/{slug}/moderation?toast=User+unmuted"
@@ -13,10 +13,10 @@
13 13 use crate::templates::*;
14 14 use crate::AppState;
15 15
16 - use mt_core::types::{CommunityState, ModAction};
16 + use mt_core::types::{CommunityState, ModAction, ModActor};
17 17
18 18 use super::{
19 - log_mod_action, parse_uuid, require_mod_or_superadmin, require_owner, template_user,
19 + audit, begin_tx, commit_tx, parse_uuid, require_mod_or_superadmin, require_owner, template_user,
20 20 validate_title, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData,
21 21 MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm,
22 22 };
@@ -116,17 +116,18 @@
116 116 .and_then(|s| s.trim().parse::<i32>().ok())
117 117 .filter(|&n| n > 0);
118 118
119 - mt_db::mutations::update_community(&state.db, community.id, name, desc_opt, threshold)
119 + let mut tx = begin_tx(&state.db).await?;
120 + mt_db::mutations::update_community(&mut *tx, community.id, name, desc_opt, threshold)
120 121 .await
121 122 .map_err(|e| {
122 123 tracing::error!(error = ?e, "db error updating community");
123 124 StatusCode::INTERNAL_SERVER_ERROR.into_response()
124 125 })?;
125 -
126 - log_mod_action(
127 - &state.db, Some(community.id), user.user_id,
126 + audit(
127 + &mut tx, Some(community.id), ModActor::User(user.user_id),
128 128 ModAction::EditSettings, None, None, None,
129 - ).await;
129 + ).await?;
130 + commit_tx(tx).await?;
130 131
131 132 Ok(Redirect::to(&format!(
132 133 "/p/{slug}/settings?toast=Settings+saved"
@@ -175,17 +176,18 @@
175 176 })?;
176 177 let next_order = existing.iter().map(|c| c.sort_order).max().unwrap_or(0) + 1;
177 178
178 - mt_db::mutations::create_category(&state.db, community.id, name, &cat_slug, desc_opt, next_order)
179 + let mut tx = begin_tx(&state.db).await?;
180 + mt_db::mutations::create_category(&mut *tx, community.id, name, &cat_slug, desc_opt, next_order)
179 181 .await
180 182 .map_err(|e| {
181 183 tracing::error!(error = ?e, "db error creating category");
182 184 StatusCode::INTERNAL_SERVER_ERROR.into_response()
183 185 })?;
184 -
185 - log_mod_action(
186 - &state.db, Some(community.id), user.user_id,
186 + audit(
187 + &mut tx, Some(community.id), ModActor::User(user.user_id),
187 188 ModAction::CreateCategory, None, None, Some(name),
188 - ).await;
189 + ).await?;
190 + commit_tx(tx).await?;
189 191
190 192 Ok(Redirect::to(&format!(
191 193 "/p/{slug}/settings?toast=Category+created"
@@ -253,20 +255,23 @@
253 255 }
254 256 let desc_opt = if description.is_empty() { None } else { Some(description) };
255 257
256 - let updated = mt_db::mutations::update_category(&state.db, cat_id, community.id, name, desc_opt)
258 + let mut tx = begin_tx(&state.db).await?;
259 + let updated = mt_db::mutations::update_category(&mut *tx, cat_id, community.id, name, desc_opt)
257 260 .await
258 261 .map_err(|e| {
259 262 tracing::error!(error = ?e, "db error updating category");
260 263 StatusCode::INTERNAL_SERVER_ERROR.into_response()
261 264 })?;
262 265 if !updated {
266 + // Nothing changed (no such category in this community); the tx rolls back
267 + // on drop, so no empty audit row is written.
263 268 return Err(StatusCode::NOT_FOUND.into_response());
264 269 }
265 -
266 - log_mod_action(
267 - &state.db, Some(community.id), user.user_id,
270 + audit(
271 + &mut tx, Some(community.id), ModActor::User(user.user_id),
268 272 ModAction::EditCategory, None, Some(cat_id), None,
269 - ).await;
273 + ).await?;
274 + commit_tx(tx).await?;
270 275
271 276 Ok(Redirect::to(&format!(
272 277 "/p/{slug}/settings?toast=Category+updated"
@@ -424,23 +429,24 @@
424 429 )));
425 430 }
426 431
427 - mt_db::mutations::set_community_state(&state.db, community.id, new_state)
432 + let mut tx = begin_tx(&state.db).await?;
433 + mt_db::mutations::set_community_state(&mut *tx, community.id, new_state)
428 434 .await
429 435 .map_err(|e| {
430 436 tracing::error!(error = ?e, "db error setting community state");
431 437 StatusCode::INTERNAL_SERVER_ERROR.into_response()
432 438 })?;
433 -
434 - log_mod_action(
435 - &state.db,
439 + audit(
440 + &mut tx,
436 441 Some(community.id),
437 - user.user_id,
442 + ModActor::User(user.user_id),
438 443 ModAction::ChangeCommunityState,
439 444 None,
440 445 None,
441 446 Some(new_state.as_str()),
442 447 )
443 - .await;
448 + .await?;
449 + commit_tx(tx).await?;
444 450
445 451 Ok(Redirect::to(&format!(
446 452 "/p/{slug}/settings?toast=Community+state+updated"