From 12d9061ff00fc043a7b12b01afdfe74bc8356fd5 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 6 Jan 2026 18:07:33 -0500 Subject: [PATCH 01/16] feat: base locking impl --- ...82d8c484c3154f45ef0fbafc0380de9bba4f0.json | 40 ++++ ...25b98efbb2f38fb2d113c2f894481f41dc24c.json | 14 ++ ...38fecfa2d81d93bc72602d14be0c61c1195e5.json | 15 ++ ...92147c88570ba9757a8204861187f0da7dbb1.json | 15 ++ ...5561f625e7edfadb21f89a41d0c16cd25763a.json | 15 ++ ...368931e5f711c6ce4e4af6b0ad523600da425.json | 12 ++ .../20260106225635_moderation_locks.sql | 8 + apps/labrinth/src/database/models/mod.rs | 3 + .../database/models/moderation_lock_item.rs | 150 ++++++++++++++ .../src/routes/internal/moderation/mod.rs | 195 +++++++++++++++++- 10 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 apps/labrinth/.sqlx/query-298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0.json create mode 100644 apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json create mode 100644 apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json create mode 100644 apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json create mode 100644 apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json create mode 100644 apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json create mode 100644 apps/labrinth/migrations/20260106225635_moderation_locks.sql create mode 100644 apps/labrinth/src/database/models/moderation_lock_item.rs diff --git a/apps/labrinth/.sqlx/query-298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0.json b/apps/labrinth/.sqlx/query-298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0.json new file mode 100644 index 0000000000..b7806d340c --- /dev/null +++ b/apps/labrinth/.sqlx/query-298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT\n\t\t\t\tml.project_id,\n\t\t\t\tml.moderator_id,\n\t\t\t\tu.username as moderator_username,\n\t\t\t\tml.locked_at\n\t\t\tFROM moderation_locks ml\n\t\t\tINNER JOIN users u ON u.id = ml.moderator_id\n\t\t\tWHERE ml.project_id = $1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "moderator_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "moderator_username", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "locked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0" +} diff --git a/apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json b/apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json new file mode 100644 index 0000000000..c08dc312d4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE moderation_locks SET locked_at = NOW() WHERE project_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "420453c85418f57da3f89396b0625b98efbb2f38fb2d113c2f894481f41dc24c" +} diff --git a/apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json b/apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json new file mode 100644 index 0000000000..bf099e2918 --- /dev/null +++ b/apps/labrinth/.sqlx/query-47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM moderation_locks WHERE project_id = $1 AND moderator_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "47df2d46f068e3158387ac8928238fecfa2d81d93bc72602d14be0c61c1195e5" +} diff --git a/apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json b/apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json new file mode 100644 index 0000000000..9d2554f1b2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE moderation_locks SET moderator_id = $1, locked_at = NOW() WHERE project_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "72c050caf23ce0e7d9f2dbe2eca92147c88570ba9757a8204861187f0da7dbb1" +} diff --git a/apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json b/apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json new file mode 100644 index 0000000000..1b4008fcaa --- /dev/null +++ b/apps/labrinth/.sqlx/query-834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO moderation_locks (project_id, moderator_id, locked_at)\n\t\t\tVALUES ($1, $2, NOW())\n\t\t\tON CONFLICT (project_id) DO UPDATE\n\t\t\tSET moderator_id = EXCLUDED.moderator_id, locked_at = EXCLUDED.locked_at", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "834f4aca2ff23ccd041cd1028145561f625e7edfadb21f89a41d0c16cd25763a" +} diff --git a/apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json b/apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json new file mode 100644 index 0000000000..1d33516a62 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "e5a83a4d578f76e6e3298054960368931e5f711c6ce4e4af6b0ad523600da425" +} diff --git a/apps/labrinth/migrations/20260106225635_moderation_locks.sql b/apps/labrinth/migrations/20260106225635_moderation_locks.sql new file mode 100644 index 0000000000..173d93dec1 --- /dev/null +++ b/apps/labrinth/migrations/20260106225635_moderation_locks.sql @@ -0,0 +1,8 @@ +CREATE TABLE moderation_locks ( + project_id BIGINT PRIMARY KEY REFERENCES mods(id) ON DELETE CASCADE, + moderator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + locked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE INDEX idx_moderation_locks_moderator ON moderation_locks(moderator_id); +CREATE INDEX idx_moderation_locks_locked_at ON moderation_locks(locked_at); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 6315fc45cf..10bd587381 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -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}")] diff --git a/apps/labrinth/src/database/models/moderation_lock_item.rs b/apps/labrinth/src/database/models/moderation_lock_item.rs new file mode 100644 index 0000000000..b8c1bd755f --- /dev/null +++ b/apps/labrinth/src/database/models/moderation_lock_item.rs @@ -0,0 +1,150 @@ +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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModerationLockWithUser { + pub project_id: DBProjectId, + pub moderator_id: DBUserId, + pub moderator_username: String, + pub locked_at: DateTime, + 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, 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, sqlx::Error> { + let row = sqlx::query!( + r#" + SELECT + ml.project_id, + ml.moderator_id, + u.username as moderator_username, + 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 = 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, + 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 { + 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 { + let result = sqlx::query!( + "DELETE FROM moderation_locks WHERE locked_at < NOW() - INTERVAL '15 minutes'" + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) + } +} diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index 7be20fc407..cb9bde2e92 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -1,5 +1,6 @@ use super::ApiError; use crate::database; +use crate::database::models::DBModerationLock; use crate::database::redis::RedisPool; use crate::models::ids::OrganizationId; use crate::models::projects::{Project, ProjectStatus}; @@ -7,8 +8,9 @@ use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; use crate::queue::session::AuthQueue; use crate::util::error::Context; use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; -use actix_web::{HttpRequest, get, post, web}; +use actix_web::{HttpRequest, delete, get, post, web}; use ariadne::ids::{UserId, random_base62}; +use chrono::{DateTime, Utc}; use ownership::get_projects_ownership; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -21,6 +23,9 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(get_projects) .service(get_project_meta) .service(set_project_meta) + .service(acquire_lock) + .service(get_lock_status) + .service(release_lock) .service( utoipa_actix_web::scope("/tech-review") .configure(tech_review::config), @@ -76,6 +81,51 @@ pub enum Ownership { }, } +/// Response for lock status check +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LockStatusResponse { + /// Whether the project is currently locked + pub locked: bool, + /// Information about who holds the lock (if locked) + #[serde(skip_serializing_if = "Option::is_none")] + pub locked_by: Option, + /// When the lock was acquired + #[serde(skip_serializing_if = "Option::is_none")] + pub locked_at: Option>, + /// Whether the lock has expired (>15 minutes old) + #[serde(skip_serializing_if = "Option::is_none")] + pub expired: Option, +} + +/// Information about the moderator holding the lock +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LockedByUser { + /// User ID (base62 encoded) + pub id: String, + /// Username + pub username: String, +} + +/// Response for successful lock acquisition +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LockAcquireResponse { + /// Whether lock was successfully acquired + pub success: bool, + /// If blocked, info about who holds the lock + #[serde(skip_serializing_if = "Option::is_none")] + pub locked_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locked_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expired: Option, +} + +/// Response for lock release +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LockReleaseResponse { + pub success: bool, +} + /// Fetch all projects which are in the moderation queue. #[utoipa::path( responses((status = OK, body = inline(Vec))) @@ -420,3 +470,146 @@ async fn set_project_meta( Ok(()) } + +/// Acquire or refresh a moderation lock on a project. +/// Returns success if acquired, or info about who holds the lock if blocked. +#[utoipa::path( + responses( + (status = OK, body = LockAcquireResponse), + (status = NOT_FOUND, description = "Project not found") + ) +)] +#[post("/lock/{project_id}")] +async fn acquire_lock( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(String,)>, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + + let project_id_str = path.into_inner().0; + let project = + database::models::DBProject::get(&project_id_str, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + let db_project_id = project.inner.id; + let db_user_id = database::models::DBUserId::from(user.id); + + match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? { + Ok(()) => Ok(web::Json(LockAcquireResponse { + success: true, + locked_by: None, + locked_at: None, + expired: None, + })), + Err(lock) => Ok(web::Json(LockAcquireResponse { + success: false, + locked_by: Some(LockedByUser { + id: UserId::from(lock.moderator_id).to_string(), + username: lock.moderator_username, + }), + locked_at: Some(lock.locked_at), + expired: Some(lock.expired), + })), + } +} + +/// Check the lock status for a project +#[utoipa::path( + responses( + (status = OK, body = LockStatusResponse), + (status = NOT_FOUND, description = "Project not found") + ) +)] +#[get("/lock/{project_id}")] +async fn get_lock_status( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(String,)>, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let project_id_str = path.into_inner().0; + let project = + database::models::DBProject::get(&project_id_str, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + let db_project_id = project.inner.id; + + match DBModerationLock::get_with_user(db_project_id, &pool).await? { + Some(lock) => Ok(web::Json(LockStatusResponse { + locked: true, + locked_by: Some(LockedByUser { + id: UserId::from(lock.moderator_id).to_string(), + username: lock.moderator_username, + }), + locked_at: Some(lock.locked_at), + expired: Some(lock.expired), + })), + None => Ok(web::Json(LockStatusResponse { + locked: false, + locked_by: None, + locked_at: None, + expired: None, + })), + } +} + +/// Release a moderation lock on a project +#[utoipa::path( + responses( + (status = OK, body = LockReleaseResponse), + (status = NOT_FOUND, description = "Project not found") + ) +)] +#[delete("/lock/{project_id}")] +async fn release_lock( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(String,)>, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + + let project_id_str = path.into_inner().0; + let project = + database::models::DBProject::get(&project_id_str, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + let db_project_id = project.inner.id; + let db_user_id = database::models::DBUserId::from(user.id); + + let released = + DBModerationLock::release(db_project_id, db_user_id, &pool).await?; + + Ok(web::Json(LockReleaseResponse { success: released })) +} From 37ed727fbd7530f5442d166488011cc94e5266d1 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 6 Jan 2026 19:30:20 -0500 Subject: [PATCH 02/16] feat: lock logic in place in rev endpoint + fetch rev --- .../src/routes/internal/moderation/mod.rs | 2 ++ apps/labrinth/src/routes/v3/projects.rs | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index cb9bde2e92..279bed9be1 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -611,5 +611,7 @@ async fn release_lock( let released = DBModerationLock::release(db_project_id, db_user_id, &pool).await?; + let _ = DBModerationLock::cleanup_expired(&pool).await; + Ok(web::Json(LockReleaseResponse { success: released })) } diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 0b93d9896d..4f03ab2b6a 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -6,7 +6,9 @@ use crate::auth::{filter_visible_projects, get_user_from_headers}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; -use crate::database::models::{DBTeamMember, ids as db_ids, image_item}; +use crate::database::models::{ + DBModerationLock, DBTeamMember, ids as db_ids, image_item, +}; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; use crate::file_hosting::{FileHost, FileHostPublicity}; @@ -368,6 +370,23 @@ pub async fn project_edit( )); } + // If a moderator is completing a review (changing from Processing to another status), + // check if another moderator holds an active lock on this project + if user.role.is_mod() + && project_item.inner.status == ProjectStatus::Processing + && status != &ProjectStatus::Processing + && let Some(lock) = + DBModerationLock::get_with_user(project_item.inner.id, &pool) + .await? + && lock.moderator_id != db_ids::DBUserId::from(user.id) + && !lock.expired + { + return Err(ApiError::CustomAuthentication(format!( + "This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", + lock.moderator_username + ))); + } + if status == &ProjectStatus::Processing { if project_item.versions.is_empty() { return Err(ApiError::InvalidInput(String::from( From c0eec3c54d55182bf3bdf454c6beb1963a604c50 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 8 Jan 2026 06:13:05 -0500 Subject: [PATCH 03/16] feat: frontend impl and finalize --- .../checklist/ModerationChecklist.vue | 738 +++++++++++------- apps/frontend/src/store/moderation.ts | 66 ++ ...a2de7f5b8b742c0196e71ab2139174fcc12f.json} | 10 +- .../database/models/moderation_lock_item.rs | 3 + .../src/routes/internal/moderation/mod.rs | 4 + 5 files changed, 557 insertions(+), 264 deletions(-) rename apps/labrinth/.sqlx/{query-298ae84addf1c6cc6df2aa265bd82d8c484c3154f45ef0fbafc0380de9bba4f0.json => query-15ce2cf3154ba3358461b375504ca2de7f5b8b742c0196e71ab2139174fcc12f.json} (66%) diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index d3dece98c7..59769bfce4 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -3,7 +3,7 @@

@@ -25,7 +25,7 @@ - @@ -38,298 +38,348 @@
-
-
-

- You are done moderating this project! - -

-
-
-
- - - - -