feat: moderation locking (#5070)

* feat: base locking impl

* feat: lock logic in place in rev endpoint + fetch rev

* feat: frontend impl and finalize

* feat: auto skip if using the moderation queue page

* fix: qa issues

* fix: async state + locking fix

* fix: lint

* fix: fmt

* fix: qa issue

* fix: qa + redirect bug

* fix: lint

* feat: delete all locks endpoint for admins

* fix: dedupe

* fix: fmt

* fix: project redirect move to middleware

* fix: lint
This commit is contained in:
Calum H.
2026-01-12 17:08:30 +00:00
committed by GitHub
parent 915d8c68bf
commit b46f6d0141
21 changed files with 1644 additions and 321 deletions

View File

@@ -11,6 +11,7 @@ pub mod ids;
pub mod image_item;
pub mod legacy_loader_fields;
pub mod loader_fields;
pub mod moderation_lock_item;
pub mod notification_item;
pub mod notifications_deliveries_item;
pub mod notifications_template_item;
@@ -53,6 +54,8 @@ pub use thread_item::{DBThread, DBThreadMessage};
pub use user_item::DBUser;
pub use version_item::DBVersion;
pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Error while interacting with the database: {0}")]

View File

@@ -0,0 +1,163 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::database::models::{DBProjectId, DBUserId};
const LOCK_EXPIRY_MINUTES: i64 = 15;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationLock {
pub project_id: DBProjectId,
pub moderator_id: DBUserId,
pub locked_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModerationLockWithUser {
pub project_id: DBProjectId,
pub moderator_id: DBUserId,
pub moderator_username: String,
pub moderator_avatar_url: Option<String>,
pub locked_at: DateTime<Utc>,
pub expired: bool,
}
impl DBModerationLock {
/// Check if a lock is expired (older than 15 minutes)
pub fn is_expired(&self) -> bool {
Utc::now()
.signed_duration_since(self.locked_at)
.num_minutes()
>= LOCK_EXPIRY_MINUTES
}
/// Try to acquire or refresh a lock for a project.
/// Returns Ok(Ok(())) if lock acquired/refreshed, Ok(Err(lock)) if blocked by another moderator.
pub async fn acquire(
project_id: DBProjectId,
moderator_id: DBUserId,
pool: &PgPool,
) -> Result<Result<(), ModerationLockWithUser>, sqlx::Error> {
// First check if there's an existing lock
let existing = Self::get_with_user(project_id, pool).await?;
if let Some(lock) = existing {
// Same moderator - refresh the lock
if lock.moderator_id == moderator_id {
sqlx::query!(
"UPDATE moderation_locks SET locked_at = NOW() WHERE project_id = $1",
project_id as DBProjectId
)
.execute(pool)
.await?;
return Ok(Ok(()));
}
// Different moderator but lock expired - take over
if lock.expired {
sqlx::query!(
"UPDATE moderation_locks SET moderator_id = $1, locked_at = NOW() WHERE project_id = $2",
moderator_id as DBUserId,
project_id as DBProjectId
)
.execute(pool)
.await?;
return Ok(Ok(()));
}
// Different moderator, not expired - blocked
return Ok(Err(lock));
}
// No existing lock - create new one
sqlx::query!(
"INSERT INTO moderation_locks (project_id, moderator_id, locked_at)
VALUES ($1, $2, NOW())
ON CONFLICT (project_id) DO UPDATE
SET moderator_id = EXCLUDED.moderator_id, locked_at = EXCLUDED.locked_at",
project_id as DBProjectId,
moderator_id as DBUserId
)
.execute(pool)
.await?;
Ok(Ok(()))
}
/// Get lock status for a project, including moderator username
pub async fn get_with_user(
project_id: DBProjectId,
pool: &PgPool,
) -> Result<Option<ModerationLockWithUser>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT
ml.project_id,
ml.moderator_id,
u.username as moderator_username,
u.avatar_url as moderator_avatar_url,
ml.locked_at
FROM moderation_locks ml
INNER JOIN users u ON u.id = ml.moderator_id
WHERE ml.project_id = $1
"#,
project_id as DBProjectId
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
let locked_at: DateTime<Utc> = r.locked_at;
let expired =
Utc::now().signed_duration_since(locked_at).num_minutes()
>= LOCK_EXPIRY_MINUTES;
ModerationLockWithUser {
project_id: DBProjectId(r.project_id),
moderator_id: DBUserId(r.moderator_id),
moderator_username: r.moderator_username,
moderator_avatar_url: r.moderator_avatar_url,
locked_at,
expired,
}
}))
}
/// Release a lock (only if held by the specified moderator)
pub async fn release(
project_id: DBProjectId,
moderator_id: DBUserId,
pool: &PgPool,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
"DELETE FROM moderation_locks WHERE project_id = $1 AND moderator_id = $2",
project_id as DBProjectId,
moderator_id as DBUserId
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Clean up expired locks (can be called periodically)
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
"DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'"
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Delete all moderation locks (admin only)
pub async fn delete_all(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query!("DELETE FROM moderation_locks")
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}