From c25e8f0957cc9b09296605d13f8aa067f026c52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 16:26:22 -0400 Subject: [PATCH 01/15] Moderator notes --- .../20260513120000_moderation_notes.sql | 25 ++ apps/labrinth/src/database/models/mod.rs | 2 + .../database/models/moderation_note_item.rs | 324 ++++++++++++++++++ apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v3/mod.rs | 1 + .../src/models/v3/moderation_notes.rs | 67 ++++ apps/labrinth/src/models/v3/organizations.rs | 4 + apps/labrinth/src/models/v3/users.rs | 5 + apps/labrinth/src/routes/mod.rs | 8 + apps/labrinth/src/routes/v2/users.rs | 25 +- apps/labrinth/src/routes/v3/organizations.rs | 87 ++++- apps/labrinth/src/routes/v3/users.rs | 124 ++++++- apps/labrinth/tests/moderation_notes.rs | 255 ++++++++++++++ 13 files changed, 900 insertions(+), 28 deletions(-) create mode 100644 apps/labrinth/migrations/20260513120000_moderation_notes.sql create mode 100644 apps/labrinth/src/database/models/moderation_note_item.rs create mode 100644 apps/labrinth/src/models/v3/moderation_notes.rs create mode 100644 apps/labrinth/tests/moderation_notes.rs diff --git a/apps/labrinth/migrations/20260513120000_moderation_notes.sql b/apps/labrinth/migrations/20260513120000_moderation_notes.sql new file mode 100644 index 0000000000..f14d1374c5 --- /dev/null +++ b/apps/labrinth/migrations/20260513120000_moderation_notes.sql @@ -0,0 +1,25 @@ +CREATE TABLE moderation_notes ( + user_id BIGINT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id BIGINT NULL REFERENCES organizations(id) ON DELETE CASCADE, + last_modified TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_author BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + version INTEGER NOT NULL DEFAULT 0, + notes TEXT NOT NULL, + user_rating INTEGER NOT NULL DEFAULT 0, + CONSTRAINT moderation_notes_one_target CHECK ( + (user_id IS NOT NULL AND organization_id IS NULL) + OR (user_id IS NULL AND organization_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX moderation_notes_user_id_unique + ON moderation_notes(user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX moderation_notes_organization_id_unique + ON moderation_notes(organization_id) + WHERE organization_id IS NOT NULL; + +CREATE INDEX moderation_notes_user_id_idx ON moderation_notes(user_id); +CREATE INDEX moderation_notes_organization_id_idx ON moderation_notes(organization_id); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 0db87c5082..bba03d18a9 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -13,6 +13,7 @@ pub mod legacy_loader_fields; pub mod loader_fields; pub mod moderation_external_item; pub mod moderation_lock_item; +pub mod moderation_note_item; pub mod notification_item; pub mod notifications_deliveries_item; pub mod notifications_template_item; @@ -56,6 +57,7 @@ pub use user_item::DBUser; pub use version_item::DBVersion; pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser}; +pub use moderation_note_item::DBModerationNote; #[derive(Error, Debug)] pub enum DatabaseError { diff --git a/apps/labrinth/src/database/models/moderation_note_item.rs b/apps/labrinth/src/database/models/moderation_note_item.rs new file mode 100644 index 0000000000..d67ec9146a --- /dev/null +++ b/apps/labrinth/src/database/models/moderation_note_item.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, postgres::PgRow}; + +use crate::database::redis::RedisPool; + +use super::{DBOrganizationId, DBUserId, DatabaseError}; + +const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users"; +const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str = + "moderation_notes_organizations"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBModerationNote { + pub user_id: Option, + pub organization_id: Option, + pub last_modified: DateTime, + pub created_at: DateTime, + pub last_author: DBUserId, + pub version: i32, + pub notes: String, + pub user_rating: i32, +} + +impl DBModerationNote { + fn from_row(row: PgRow) -> Result { + Ok(Self { + user_id: row.try_get::, _>("user_id")?.map(DBUserId), + organization_id: row + .try_get::, _>("organization_id")? + .map(DBOrganizationId), + last_modified: row.try_get("last_modified")?, + created_at: row.try_get("created_at")?, + last_author: DBUserId(row.try_get("last_author")?), + version: row.try_get("version")?, + notes: row.try_get("notes")?, + user_rating: row.try_get("user_rating")?, + }) + } + + pub async fn get_many_users<'a, E>( + user_ids: &[DBUserId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let ids = user_ids + .iter() + .map(|id| id.0.to_string()) + .collect::>(); + + let cached = { + let mut redis = redis.connect().await?; + redis + .get_many_deserialized_from_json::( + MODERATION_NOTES_USERS_NAMESPACE, + &ids, + ) + .await? + }; + + let mut notes = HashMap::new(); + let mut missing_ids = Vec::new(); + for (id, cached_note) in user_ids.iter().copied().zip(cached) { + if let Some(note) = cached_note { + notes.insert(id, note); + } else { + missing_ids.push(id.0); + } + } + + if missing_ids.is_empty() { + return Ok(notes); + } + + let rows = sqlx::query( + r#" + SELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + FROM moderation_notes + WHERE user_id = ANY($1) + "#, + ) + .bind(&missing_ids) + .fetch_all(exec) + .await?; + + let mut redis = redis.connect().await?; + for row in rows { + let note = Self::from_row(row)?; + + if let Some(user_id) = note.user_id { + redis + .set_serialized_to_json( + MODERATION_NOTES_USERS_NAMESPACE, + user_id.0, + ¬e, + None, + ) + .await?; + notes.insert(user_id, note); + } + } + + Ok(notes) + } + + pub async fn get_user<'a, E>( + user_id: DBUserId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::get_many_users(&[user_id], exec, redis) + .await? + .remove(&user_id)) + } + + pub async fn get_many_organizations<'a, E>( + organization_ids: &[DBOrganizationId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let ids = organization_ids + .iter() + .map(|id| id.0.to_string()) + .collect::>(); + + let cached = { + let mut redis = redis.connect().await?; + redis + .get_many_deserialized_from_json::( + MODERATION_NOTES_ORGANIZATIONS_NAMESPACE, + &ids, + ) + .await? + }; + + let mut notes = HashMap::new(); + let mut missing_ids = Vec::new(); + for (id, cached_note) in organization_ids.iter().copied().zip(cached) { + if let Some(note) = cached_note { + notes.insert(id, note); + } else { + missing_ids.push(id.0); + } + } + + if missing_ids.is_empty() { + return Ok(notes); + } + + let rows = sqlx::query( + r#" + SELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + FROM moderation_notes + WHERE organization_id = ANY($1) + "#, + ) + .bind(&missing_ids) + .fetch_all(exec) + .await?; + + let mut redis = redis.connect().await?; + for row in rows { + let note = Self::from_row(row)?; + + if let Some(organization_id) = note.organization_id { + redis + .set_serialized_to_json( + MODERATION_NOTES_ORGANIZATIONS_NAMESPACE, + organization_id.0, + ¬e, + None, + ) + .await?; + notes.insert(organization_id, note); + } + } + + Ok(notes) + } + + pub async fn get_organization<'a, E>( + organization_id: DBOrganizationId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok( + Self::get_many_organizations(&[organization_id], exec, redis) + .await? + .remove(&organization_id), + ) + } + + pub async fn patch_user<'a, E>( + user_id: DBUserId, + last_author: DBUserId, + expected_version: i32, + notes: Option<&str>, + user_rating: Option, + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query( + r#" + WITH updated AS ( + UPDATE moderation_notes + SET + last_modified = NOW(), + last_author = $2, + version = version + 1, + notes = COALESCE($4::text, notes), + user_rating = COALESCE($5::integer, user_rating) + WHERE user_id = $1 AND version = $3::integer + RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + ), + inserted AS ( + INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating) + SELECT $1, NULL::bigint, $2, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0) + WHERE $3::integer = 0 AND NOT EXISTS ( + SELECT 1 FROM moderation_notes WHERE user_id = $1 + ) + ON CONFLICT DO NOTHING + RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + ) + SELECT * FROM updated + UNION ALL + SELECT * FROM inserted + "#, + ) + .bind(user_id.0) + .bind(last_author.0) + .bind(expected_version) + .bind(notes) + .bind(user_rating) + .fetch_optional(exec) + .await?; + + Ok(row.map(Self::from_row).transpose()?) + } + + pub async fn patch_organization<'a, E>( + organization_id: DBOrganizationId, + last_author: DBUserId, + expected_version: i32, + notes: Option<&str>, + user_rating: Option, + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query( + r#" + WITH updated AS ( + UPDATE moderation_notes + SET + last_modified = NOW(), + last_author = $2, + version = version + 1, + notes = COALESCE($4::text, notes), + user_rating = COALESCE($5::integer, user_rating) + WHERE organization_id = $1 AND version = $3::integer + RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + ), + inserted AS ( + INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating) + SELECT NULL::bigint, $1, $2, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0) + WHERE $3::integer = 0 AND NOT EXISTS ( + SELECT 1 FROM moderation_notes WHERE organization_id = $1 + ) + ON CONFLICT DO NOTHING + RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating + ) + SELECT * FROM updated + UNION ALL + SELECT * FROM inserted + "#, + ) + .bind(organization_id.0) + .bind(last_author.0) + .bind(expected_version) + .bind(notes) + .bind(user_rating) + .fetch_optional(exec) + .await?; + + Ok(row.map(Self::from_row).transpose()?) + } + + pub async fn clear_user_cache( + user_id: DBUserId, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + redis + .delete(MODERATION_NOTES_USERS_NAMESPACE, user_id.0) + .await + } + + pub async fn clear_organization_cache( + organization_id: DBOrganizationId, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + redis + .delete(MODERATION_NOTES_ORGANIZATIONS_NAMESPACE, organization_id.0) + .await + } +} diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index 13be1a318d..f80f4de9ee 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -8,6 +8,7 @@ pub use v3::billing; pub use v3::collections; pub use v3::ids; pub use v3::images; +pub use v3::moderation_notes; pub use v3::notifications; pub use v3::oauth_clients; pub use v3::organizations; diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 9e87e679a0..ea4a526d23 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -4,6 +4,7 @@ pub mod billing; pub mod collections; pub mod ids; pub mod images; +pub mod moderation_notes; pub mod notifications; pub mod oauth_clients; pub mod organizations; diff --git a/apps/labrinth/src/models/v3/moderation_notes.rs b/apps/labrinth/src/models/v3/moderation_notes.rs new file mode 100644 index 0000000000..2926b65084 --- /dev/null +++ b/apps/labrinth/src/models/v3/moderation_notes.rs @@ -0,0 +1,67 @@ +use actix_web::{HttpRequest, http::header::IF_MATCH}; +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::routes::ApiError; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ModerationNote { + pub notes: String, + pub last_modified: DateTime, + pub created_at: DateTime, + pub last_author: UserId, + pub user_rating: i32, + pub version: i32, +} + +impl From for ModerationNote { + fn from(note: crate::database::models::DBModerationNote) -> Self { + Self { + notes: note.notes, + last_modified: note.last_modified, + created_at: note.created_at, + last_author: note.last_author.into(), + user_rating: note.user_rating, + version: note.version, + } + } +} + +#[derive(Deserialize)] +pub struct PatchModerationNote { + pub notes: Option, + pub user_rating: Option, +} + +impl PatchModerationNote { + pub fn validate_not_empty(&self) -> Result<(), ApiError> { + if self.notes.is_none() && self.user_rating.is_none() { + return Err(ApiError::InvalidInput( + "must specify `notes` or `user_rating`".to_string(), + )); + } + + Ok(()) + } +} + +pub fn parse_if_match_header(req: &HttpRequest) -> Result { + let value = req.headers().get(IF_MATCH).ok_or_else(|| { + ApiError::PreconditionRequired( + "`if-match` header is required".to_string(), + ) + })?; + + let value = value.to_str().map_err(|_| { + ApiError::InvalidInput( + "`if-match` header must be a valid integer".to_string(), + ) + })?; + + value.parse::().map_err(|_| { + ApiError::InvalidInput( + "`if-match` header must be a valid integer".to_string(), + ) + }) +} diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index 4ec48cdc36..dcec44df3c 100644 --- a/apps/labrinth/src/models/v3/organizations.rs +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -1,3 +1,4 @@ +use super::moderation_notes::ModerationNote; use super::teams::TeamMember; use crate::models::ids::{OrganizationId, TeamId}; use serde::{Deserialize, Serialize}; @@ -23,6 +24,8 @@ pub struct Organization { /// A list of the members of the organization pub members: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option>, } impl Organization { @@ -39,6 +42,7 @@ impl Organization { members: team_members, icon_url: data.icon_url, color: data.color, + notes: None, } } } diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 0f276fc8fa..39769db22d 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -1,3 +1,4 @@ +use super::moderation_notes::ModerationNote; use crate::{auth::AuthProvider, bitflags_serde_impl}; use ariadne::ids::UserId; pub use ariadne::users::UserStatus; @@ -64,6 +65,8 @@ pub struct User { pub payout_data: Option, pub stripe_customer_id: Option, pub allow_friend_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option>, // DEPRECATED. Always returns None pub github_id: Option, @@ -98,6 +101,7 @@ impl From for User { github_id: None, stripe_customer_id: None, allow_friend_requests: None, + notes: None, } } } @@ -150,6 +154,7 @@ impl User { }), stripe_customer_id: db_user.stripe_customer_id, allow_friend_requests: Some(db_user.allow_friend_requests), + notes: None, } } } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index fa77f693da..178a8a706b 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -141,6 +141,10 @@ pub enum ApiError { NotFound, #[error("Conflict: {0}")] Conflict(String), + #[error("precondition required: {0}")] + PreconditionRequired(String), + #[error("precondition failed: {0}")] + PreconditionFailed(String), #[error("External tax compliance API error")] TaxComplianceApi, #[error(transparent)] @@ -194,6 +198,8 @@ impl ApiError { Self::Reroute(..) => "reroute_error", Self::NotFound => "not_found", Self::Conflict(..) => "conflict", + Self::PreconditionRequired(..) => "precondition_required", + Self::PreconditionFailed(..) => "precondition_failed", Self::TaxComplianceApi => "tax_compliance_api_error", Self::Zip(..) => "zip_error", Self::Io(..) => "io_error", @@ -256,6 +262,8 @@ impl actix_web::ResponseError for ApiError { Self::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound => StatusCode::NOT_FOUND, Self::Conflict(..) => StatusCode::CONFLICT, + Self::PreconditionRequired(..) => StatusCode::PRECONDITION_REQUIRED, + Self::PreconditionFailed(..) => StatusCode::PRECONDITION_FAILED, Self::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR, Self::Zip(..) => StatusCode::BAD_REQUEST, Self::Io(..) => StatusCode::BAD_REQUEST, diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 10de37dbfa..7585d2dddc 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -1,4 +1,5 @@ use crate::database::PgPool; +use crate::database::models::DBUser; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::notifications::Notification; @@ -82,23 +83,15 @@ pub async fn users_get( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::users::users_get( - web::Query(v3::users::UserIds { ids: ids.ids }), - pool, - redis, - ) - .await - .or_else(v2_reroute::flatten_404_error)?; + let user_ids = serde_json::from_str::>(&ids.ids)?; + let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?; - // Convert response to V2 format - match v2_reroute::extract_ok_json::>(response).await { - Ok(users) => { - let legacy_users: Vec = - users.into_iter().map(LegacyUser::from).collect(); - Ok(HttpResponse::Ok().json(legacy_users)) - } - Err(response) => Ok(response), - } + let legacy_users: Vec = users_data + .into_iter() + .map(crate::models::users::User::from) + .map(LegacyUser::from) + .collect(); + Ok(HttpResponse::Ok().json(legacy_users)) } /// Get a user by ID or username. diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 890c6a4e42..1e6c569895 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -7,7 +7,7 @@ use crate::auth::{filter_visible_projects, get_user_from_headers}; use crate::database::PgPool; use crate::database::models::team_item::DBTeamMember; use crate::database::models::{ - DBOrganization, generate_organization_id, team_item, + DBModerationNote, DBOrganization, generate_organization_id, team_item, }; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostPublicity}; @@ -34,6 +34,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("organization") .route("", web::post().to(organization_create)) .route("{id}/projects", web::get().to(organization_projects_get)) + .route("{id}/notes", web::patch().to(organization_notes_edit)) .route("{id}", web::get().to(organization_get)) .route("{id}", web::patch().to(organizations_edit)) .route("{id}", web::delete().to(organization_delete)) @@ -283,13 +284,78 @@ pub async fn organization_get( }) .collect(); - let organization = + let mut organization = models::organizations::Organization::from(data, team_members); + if current_user.as_ref().is_some_and(|x| x.role.is_mod()) { + let note = DBModerationNote::get_organization( + organization.id.into(), + &**pool, + &redis, + ) + .await?; + organization.notes = Some(note.map(Into::into)); + } return Ok(HttpResponse::Ok().json(organization)); } Err(ApiError::NotFound) } +pub async fn organization_notes_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_note: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + if !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "you do not have permission to edit moderation notes".to_string(), + )); + } + + new_note.validate_not_empty()?; + let expected_version = + crate::models::moderation_notes::parse_if_match_header(&req)?; + + let organization = + DBOrganization::get(&info.into_inner().0, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + let mut transaction = pool.begin().await?; + let updated = DBModerationNote::patch_organization( + organization.id, + user.id.into(), + expected_version, + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } + + transaction.commit().await?; + DBModerationNote::clear_organization_cache(organization.id, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + #[derive(Deserialize)] pub struct OrganizationIds { pub ids: String, @@ -331,6 +397,17 @@ pub async fn organizations_get( .map(|x| x.1) .ok(); let user_id = current_user.as_ref().map(|x| x.id.into()); + let include_notes = current_user.as_ref().is_some_and(|x| x.role.is_mod()); + let mut notes = if include_notes { + DBModerationNote::get_many_organizations( + &organizations_data.iter().map(|x| x.id).collect::>(), + &**pool, + &redis, + ) + .await? + } else { + HashMap::new() + }; let mut organizations = vec![]; @@ -375,8 +452,12 @@ pub async fn organizations_get( }) .collect(); - let organization = + let data_id = data.id; + let mut organization = models::organizations::Organization::from(data, team_members); + if include_notes { + organization.notes = Some(notes.remove(&data_id).map(Into::into)); + } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 708393cbed..2a79161b4d 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -8,7 +8,10 @@ use crate::{ checks::is_visible_organization, filter_visible_collections, filter_visible_projects, get_user_from_headers, }, - database::{models::DBUser, redis::RedisPool}, + database::{ + models::{DBModerationNote, DBUser}, + redis::RedisPool, + }, file_hosting::{FileHost, FileHostPublicity}, models::{ notifications::Notification, @@ -35,6 +38,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("user") .route("{user_id}/projects", web::get().to(projects_list)) + .route("{id}/notes", web::patch().to(user_notes_edit)) .route("{id}", web::get().to(user_get)) .route("{user_id}/collections", web::get().to(collections_list)) .route("{user_id}/organizations", web::get().to(orgs_list)) @@ -167,6 +171,12 @@ pub async fn user_auth_get( user.payout_data = None; } + if user.role.is_mod() { + let note = + DBModerationNote::get_user(user.id.into(), &**pool, &redis).await?; + user.notes = Some(note.map(Into::into)); + } + Ok(HttpResponse::Ok().json(user)) } @@ -176,16 +186,48 @@ pub struct UserIds { } pub async fn users_get( + req: HttpRequest, web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let user_ids = serde_json::from_str::>(&ids.ids)?; let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?; - let users: Vec = - users_data.into_iter().map(From::from).collect(); + let auth_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await + .map(|x| x.1) + .ok(); + + let mut notes = if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + DBModerationNote::get_many_users( + &users_data.iter().map(|x| x.id).collect::>(), + &**pool, + &redis, + ) + .await? + } else { + HashMap::new() + }; + + let users: Vec = users_data + .into_iter() + .map(|data| { + let mut user = crate::models::users::User::from(data.clone()); + if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + user.notes = Some(notes.remove(&data.id).map(Into::into)); + } + user + }) + .collect(); Ok(HttpResponse::Ok().json(users)) } @@ -211,12 +253,21 @@ pub async fn user_get( .map(|x| x.1) .ok(); - let response: crate::models::users::User = - if auth_user.is_some_and(|x| x.role.is_admin()) { - crate::models::users::User::from_full(data) - } else { - data.into() - }; + let is_admin = auth_user.as_ref().is_some_and(|x| x.role.is_admin()); + let is_mod = auth_user.as_ref().is_some_and(|x| x.role.is_mod()); + let user_id = data.id; + + let mut response: crate::models::users::User = if is_admin { + crate::models::users::User::from_full(data) + } else { + data.into() + }; + + if is_mod { + let note = + DBModerationNote::get_user(user_id, &**pool, &redis).await?; + response.notes = Some(note.map(Into::into)); + } Ok(HttpResponse::Ok().json(response)) } else { @@ -224,6 +275,61 @@ pub async fn user_get( } } +pub async fn user_notes_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_note: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + if !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "you do not have permission to edit moderation notes".to_string(), + )); + } + + new_note.validate_not_empty()?; + let expected_version = + crate::models::moderation_notes::parse_if_match_header(&req)?; + + let user_data = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + let mut transaction = pool.begin().await?; + let updated = DBModerationNote::patch_user( + user_data.id, + user.id.into(), + expected_version, + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } + + transaction.commit().await?; + DBModerationNote::clear_user_cache(user_data.id, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + pub async fn collections_list( req: HttpRequest, info: web::Path<(String,)>, diff --git a/apps/labrinth/tests/moderation_notes.rs b/apps/labrinth/tests/moderation_notes.rs new file mode 100644 index 0000000000..9d2643a2ee --- /dev/null +++ b/apps/labrinth/tests/moderation_notes.rs @@ -0,0 +1,255 @@ +use actix_http::StatusCode; +use actix_web::test; +use common::{ + api_common::{Api, AppendsOptionalPat}, + database::{MOD_USER_PAT, USER_USER_ID, USER_USER_PAT}, + environment::with_test_environment_all, +}; +use serde_json::{Value, json}; + +pub mod common; + +#[actix_rt::test] +pub async fn moderation_notes_users() { + with_test_environment_all(None, |test_env| async move { + let api = test_env.api; + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/user/{USER_USER_ID}")) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body.get("notes").unwrap().is_null()); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/user/{USER_USER_ID}")) + .append_pat(USER_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body.get("notes").is_none()); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(MOD_USER_PAT) + .set_json(json!({ "notes": "first note" })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::PRECONDITION_REQUIRED); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "0")) + .set_json(json!({})) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(USER_USER_PAT) + .append_header(("If-Match", "0")) + .set_json(json!({ "notes": "first note" })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "0")) + .set_json(json!({ + "notes": "first note", + "user_rating": 2, + })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/user/{USER_USER_ID}")) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["notes"]["notes"], "first note"); + assert_eq!(body["notes"]["user_rating"], 2); + assert_eq!(body["notes"]["version"], 1); + assert_eq!(body["notes"]["last_author"], "2"); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "0")) + .set_json(json!({ "notes": "stale note" })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::PRECONDITION_FAILED); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{USER_USER_ID}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "1")) + .set_json(json!({ "user_rating": 4 })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/user/{USER_USER_ID}")) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["notes"]["notes"], "first note"); + assert_eq!(body["notes"]["user_rating"], 4); + assert_eq!(body["notes"]["version"], 2); + + let user_ids = serde_json::to_string(&vec![USER_USER_ID]).unwrap(); + let resp = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/users?ids={}", + urlencoding::encode(&user_ids) + )) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body[0]["notes"]["version"], 2); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/users?ids={}", + urlencoding::encode(&user_ids) + )) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body[0].get("notes").is_none()); + }) + .await; +} + +#[actix_rt::test] +pub async fn moderation_notes_organizations() { + with_test_environment_all(None, |test_env| async move { + let api = test_env.api; + let organization_id = + test_env.dummy.organization_zeta.organization_id.clone(); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/organization/{organization_id}")) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body.get("notes").unwrap().is_null()); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/organization/{organization_id}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "0")) + .set_json(json!({ + "notes": "org note", + "user_rating": -1, + })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = api + .call( + test::TestRequest::get() + .uri(&format!("/v3/organization/{organization_id}")) + .append_pat(USER_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body.get("notes").is_none()); + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/organization/{organization_id}/notes")) + .append_pat(MOD_USER_PAT) + .append_header(("If-Match", "1")) + .set_json(json!({ "notes": "updated org note" })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let ids = + serde_json::to_string(&vec![organization_id.as_str()]).unwrap(); + let resp = api + .call( + test::TestRequest::get() + .uri(&format!( + "/v3/organizations?ids={}", + urlencoding::encode(&ids) + )) + .append_pat(MOD_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body[0]["notes"]["notes"], "updated org note"); + assert_eq!(body[0]["notes"]["user_rating"], -1); + assert_eq!(body[0]["notes"]["version"], 2); + }) + .await; +} From 60a4a04ffa88e8716c1b7ae2025a17deb0bd7b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 16:44:05 -0400 Subject: [PATCH 02/15] Use macros --- .../database/models/moderation_note_item.rs | 168 ++++++++++++------ 1 file changed, 112 insertions(+), 56 deletions(-) diff --git a/apps/labrinth/src/database/models/moderation_note_item.rs b/apps/labrinth/src/database/models/moderation_note_item.rs index d67ec9146a..ae610c1a6f 100644 --- a/apps/labrinth/src/database/models/moderation_note_item.rs +++ b/apps/labrinth/src/database/models/moderation_note_item.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{Row, postgres::PgRow}; use crate::database::redis::RedisPool; @@ -25,21 +24,6 @@ pub struct DBModerationNote { } impl DBModerationNote { - fn from_row(row: PgRow) -> Result { - Ok(Self { - user_id: row.try_get::, _>("user_id")?.map(DBUserId), - organization_id: row - .try_get::, _>("organization_id")? - .map(DBOrganizationId), - last_modified: row.try_get("last_modified")?, - created_at: row.try_get("created_at")?, - last_author: DBUserId(row.try_get("last_author")?), - version: row.try_get("version")?, - notes: row.try_get("notes")?, - user_rating: row.try_get("user_rating")?, - }) - } - pub async fn get_many_users<'a, E>( user_ids: &[DBUserId], exec: E, @@ -77,20 +61,29 @@ impl DBModerationNote { return Ok(notes); } - let rows = sqlx::query( - r#" + let rows = sqlx::query!( + r#" SELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating FROM moderation_notes WHERE user_id = ANY($1) "#, - ) - .bind(&missing_ids) - .fetch_all(exec) - .await?; + &missing_ids, + ) + .fetch_all(exec) + .await?; let mut redis = redis.connect().await?; for row in rows { - let note = Self::from_row(row)?; + let note = Self { + user_id: row.user_id.map(DBUserId), + organization_id: row.organization_id.map(DBOrganizationId), + last_modified: row.last_modified, + created_at: row.created_at, + last_author: DBUserId(row.last_author), + version: row.version, + notes: row.notes, + user_rating: row.user_rating, + }; if let Some(user_id) = note.user_id { redis @@ -158,20 +151,29 @@ impl DBModerationNote { return Ok(notes); } - let rows = sqlx::query( - r#" + let rows = sqlx::query!( + r#" SELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating FROM moderation_notes WHERE organization_id = ANY($1) "#, - ) - .bind(&missing_ids) - .fetch_all(exec) - .await?; + &missing_ids, + ) + .fetch_all(exec) + .await?; let mut redis = redis.connect().await?; for row in rows { - let note = Self::from_row(row)?; + let note = Self { + user_id: row.user_id.map(DBUserId), + organization_id: row.organization_id.map(DBOrganizationId), + last_modified: row.last_modified, + created_at: row.created_at, + last_author: DBUserId(row.last_author), + version: row.version, + notes: row.notes, + user_rating: row.user_rating, + }; if let Some(organization_id) = note.organization_id { redis @@ -215,8 +217,8 @@ impl DBModerationNote { where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - let row = sqlx::query( - r#" + let row = sqlx::query!( + r#" WITH updated AS ( UPDATE moderation_notes SET @@ -237,20 +239,47 @@ impl DBModerationNote { ON CONFLICT DO NOTHING RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating ) - SELECT * FROM updated + SELECT + user_id, + organization_id, + last_modified AS "last_modified!", + created_at AS "created_at!", + last_author AS "last_author!", + version AS "version!", + notes AS "notes!", + user_rating AS "user_rating!" + FROM updated UNION ALL - SELECT * FROM inserted + SELECT + user_id, + organization_id, + last_modified AS "last_modified!", + created_at AS "created_at!", + last_author AS "last_author!", + version AS "version!", + notes AS "notes!", + user_rating AS "user_rating!" + FROM inserted "#, - ) - .bind(user_id.0) - .bind(last_author.0) - .bind(expected_version) - .bind(notes) - .bind(user_rating) - .fetch_optional(exec) - .await?; + user_id as DBUserId, + last_author as DBUserId, + expected_version, + notes, + user_rating, + ) + .fetch_optional(exec) + .await?; - Ok(row.map(Self::from_row).transpose()?) + Ok(row.map(|row| Self { + user_id: row.user_id.map(DBUserId), + organization_id: row.organization_id.map(DBOrganizationId), + last_modified: row.last_modified, + created_at: row.created_at, + last_author: DBUserId(row.last_author), + version: row.version, + notes: row.notes, + user_rating: row.user_rating, + })) } pub async fn patch_organization<'a, E>( @@ -264,8 +293,8 @@ impl DBModerationNote { where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - let row = sqlx::query( - r#" + let row = sqlx::query!( + r#" WITH updated AS ( UPDATE moderation_notes SET @@ -286,20 +315,47 @@ impl DBModerationNote { ON CONFLICT DO NOTHING RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating ) - SELECT * FROM updated + SELECT + user_id, + organization_id, + last_modified AS "last_modified!", + created_at AS "created_at!", + last_author AS "last_author!", + version AS "version!", + notes AS "notes!", + user_rating AS "user_rating!" + FROM updated UNION ALL - SELECT * FROM inserted + SELECT + user_id, + organization_id, + last_modified AS "last_modified!", + created_at AS "created_at!", + last_author AS "last_author!", + version AS "version!", + notes AS "notes!", + user_rating AS "user_rating!" + FROM inserted "#, - ) - .bind(organization_id.0) - .bind(last_author.0) - .bind(expected_version) - .bind(notes) - .bind(user_rating) - .fetch_optional(exec) - .await?; + organization_id as DBOrganizationId, + last_author as DBUserId, + expected_version, + notes, + user_rating, + ) + .fetch_optional(exec) + .await?; - Ok(row.map(Self::from_row).transpose()?) + Ok(row.map(|row| Self { + user_id: row.user_id.map(DBUserId), + organization_id: row.organization_id.map(DBOrganizationId), + last_modified: row.last_modified, + created_at: row.created_at, + last_author: DBUserId(row.last_author), + version: row.version, + notes: row.notes, + user_rating: row.user_rating, + })) } pub async fn clear_user_cache( From 0ec94ba8210aa8738be3c1ba65f393f50cdfa7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 17:25:50 -0400 Subject: [PATCH 03/15] Improve queries --- .../database/models/moderation_note_item.rs | 166 +++++------------- apps/labrinth/src/routes/v3/organizations.rs | 31 +++- apps/labrinth/src/routes/v3/users.rs | 31 +++- 3 files changed, 90 insertions(+), 138 deletions(-) diff --git a/apps/labrinth/src/database/models/moderation_note_item.rs b/apps/labrinth/src/database/models/moderation_note_item.rs index ae610c1a6f..3df5c8c855 100644 --- a/apps/labrinth/src/database/models/moderation_note_item.rs +++ b/apps/labrinth/src/database/models/moderation_note_item.rs @@ -206,156 +206,82 @@ impl DBModerationNote { ) } - pub async fn patch_user<'a, E>( - user_id: DBUserId, + pub async fn insert<'a, E>( + user_id: Option, + organization_id: Option, last_author: DBUserId, - expected_version: i32, notes: Option<&str>, user_rating: Option, exec: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - let row = sqlx::query!( + let result = sqlx::query_scalar!( r#" - WITH updated AS ( - UPDATE moderation_notes - SET - last_modified = NOW(), - last_author = $2, - version = version + 1, - notes = COALESCE($4::text, notes), - user_rating = COALESCE($5::integer, user_rating) - WHERE user_id = $1 AND version = $3::integer - RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating - ), - inserted AS ( - INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating) - SELECT $1, NULL::bigint, $2, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0) - WHERE $3::integer = 0 AND NOT EXISTS ( - SELECT 1 FROM moderation_notes WHERE user_id = $1 - ) - ON CONFLICT DO NOTHING - RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating - ) - SELECT - user_id, - organization_id, - last_modified AS "last_modified!", - created_at AS "created_at!", - last_author AS "last_author!", - version AS "version!", - notes AS "notes!", - user_rating AS "user_rating!" - FROM updated - UNION ALL - SELECT - user_id, - organization_id, - last_modified AS "last_modified!", - created_at AS "created_at!", - last_author AS "last_author!", - version AS "version!", - notes AS "notes!", - user_rating AS "user_rating!" - FROM inserted - "#, - user_id as DBUserId, - last_author as DBUserId, - expected_version, + INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating) + SELECT + $1, $2, $3, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0) + WHERE NOT EXISTS ( + SELECT 1 FROM moderation_notes + WHERE + ($1::bigint IS NOT NULL AND user_id = $1) + OR ($2::bigint IS NOT NULL AND organization_id = $2) + ) + ON CONFLICT DO NOTHING + RETURNING version + "#, + user_id.map(|x| x.0), + organization_id.map(|x| x.0), + last_author.0, notes, user_rating, ) .fetch_optional(exec) .await?; - Ok(row.map(|row| Self { - user_id: row.user_id.map(DBUserId), - organization_id: row.organization_id.map(DBOrganizationId), - last_modified: row.last_modified, - created_at: row.created_at, - last_author: DBUserId(row.last_author), - version: row.version, - notes: row.notes, - user_rating: row.user_rating, - })) + Ok(result) } - pub async fn patch_organization<'a, E>( - organization_id: DBOrganizationId, + pub async fn update<'a, E>( + user_id: Option, + organization_id: Option, last_author: DBUserId, - expected_version: i32, + expected_current_version: i32, notes: Option<&str>, user_rating: Option, exec: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - let row = sqlx::query!( + let result = sqlx::query_scalar!( r#" - WITH updated AS ( - UPDATE moderation_notes - SET - last_modified = NOW(), - last_author = $2, - version = version + 1, - notes = COALESCE($4::text, notes), - user_rating = COALESCE($5::integer, user_rating) - WHERE organization_id = $1 AND version = $3::integer - RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating - ), - inserted AS ( - INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating) - SELECT NULL::bigint, $1, $2, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0) - WHERE $3::integer = 0 AND NOT EXISTS ( - SELECT 1 FROM moderation_notes WHERE organization_id = $1 - ) - ON CONFLICT DO NOTHING - RETURNING user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating - ) - SELECT - user_id, - organization_id, - last_modified AS "last_modified!", - created_at AS "created_at!", - last_author AS "last_author!", - version AS "version!", - notes AS "notes!", - user_rating AS "user_rating!" - FROM updated - UNION ALL - SELECT - user_id, - organization_id, - last_modified AS "last_modified!", - created_at AS "created_at!", - last_author AS "last_author!", - version AS "version!", - notes AS "notes!", - user_rating AS "user_rating!" - FROM inserted - "#, - organization_id as DBOrganizationId, - last_author as DBUserId, - expected_version, + UPDATE moderation_notes + SET + last_modified = NOW(), + last_author = $1, + version = version + 1, + notes = COALESCE($2::text, notes), + user_rating = COALESCE($3::integer, user_rating) + WHERE ( + ($4::bigint IS NOT NULL AND user_id = $4) OR + ($5::bigint IS NOT NULL AND organization_id = $5) + ) + AND version = $6 + RETURNING version + "#, + last_author.0, notes, user_rating, + user_id.map(|x| x.0), + organization_id.map(|x| x.0), + expected_current_version ) .fetch_optional(exec) .await?; - Ok(row.map(|row| Self { - user_id: row.user_id.map(DBUserId), - organization_id: row.organization_id.map(DBOrganizationId), - last_modified: row.last_modified, - created_at: row.created_at, - last_author: DBUserId(row.last_author), - version: row.version, - notes: row.notes, - user_rating: row.user_rating, - })) + Ok(result) } pub async fn clear_user_cache( diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 1e6c569895..783b2c61c6 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -334,15 +334,28 @@ pub async fn organization_notes_edit( .ok_or(ApiError::NotFound)?; let mut transaction = pool.begin().await?; - let updated = DBModerationNote::patch_organization( - organization.id, - user.id.into(), - expected_version, - new_note.notes.as_deref(), - new_note.user_rating, - &mut transaction, - ) - .await?; + let updated = if expected_version == 0 { + DBModerationNote::insert( + None, + Some(organization.id), + user.id.into(), + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await? + } else { + DBModerationNote::update( + None, + Some(organization.id), + user.id.into(), + expected_version, + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await? + }; if updated.is_none() { return Err(ApiError::PreconditionFailed( diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 2a79161b4d..ab804fe0b2 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -308,15 +308,28 @@ pub async fn user_notes_edit( .ok_or(ApiError::NotFound)?; let mut transaction = pool.begin().await?; - let updated = DBModerationNote::patch_user( - user_data.id, - user.id.into(), - expected_version, - new_note.notes.as_deref(), - new_note.user_rating, - &mut transaction, - ) - .await?; + let updated = if expected_version == 0 { + DBModerationNote::insert( + Some(user_data.id), + None, + user.id.into(), + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await? + } else { + DBModerationNote::update( + Some(user_data.id), + None, + user.id.into(), + expected_version, + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await? + }; if updated.is_none() { return Err(ApiError::PreconditionFailed( From eaf5c15075537ae043d31fa9d5a97aa5c3ed2186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 17:46:30 -0400 Subject: [PATCH 04/15] Query cache --- ...456440d1a4d687235c28d71bec7aea7bff6e7.json | 26 ++++++++ ...b7a9710aeadd5f97e3c25521202e9e21e261e.json | 64 +++++++++++++++++++ ...09e1653c4442d9ace4105dd18aa5333c52aa2.json | 27 ++++++++ ...21e51871ec591bc0e7ff29119834e3ed8bf53.json | 64 +++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-1ca9b7c343ccdc4b0ac66c67d1b456440d1a4d687235c28d71bec7aea7bff6e7.json create mode 100644 apps/labrinth/.sqlx/query-5894d30f04a5413a607f2417cd3b7a9710aeadd5f97e3c25521202e9e21e261e.json create mode 100644 apps/labrinth/.sqlx/query-5a86ade76259aafdd5b778e5b1909e1653c4442d9ace4105dd18aa5333c52aa2.json create mode 100644 apps/labrinth/.sqlx/query-ad49878323e942bdbaac87c351621e51871ec591bc0e7ff29119834e3ed8bf53.json diff --git a/apps/labrinth/.sqlx/query-1ca9b7c343ccdc4b0ac66c67d1b456440d1a4d687235c28d71bec7aea7bff6e7.json b/apps/labrinth/.sqlx/query-1ca9b7c343ccdc4b0ac66c67d1b456440d1a4d687235c28d71bec7aea7bff6e7.json new file mode 100644 index 0000000000..9f6edfdd8d --- /dev/null +++ b/apps/labrinth/.sqlx/query-1ca9b7c343ccdc4b0ac66c67d1b456440d1a4d687235c28d71bec7aea7bff6e7.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO moderation_notes (user_id, organization_id, last_author, version, notes, user_rating)\n SELECT\n $1, $2, $3, 1, COALESCE($4::text, ''), COALESCE($5::integer, 0)\n WHERE NOT EXISTS (\n SELECT 1 FROM moderation_notes\n WHERE\n ($1::bigint IS NOT NULL AND user_id = $1)\n OR ($2::bigint IS NOT NULL AND organization_id = $2)\n )\n ON CONFLICT DO NOTHING\n RETURNING version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1ca9b7c343ccdc4b0ac66c67d1b456440d1a4d687235c28d71bec7aea7bff6e7" +} diff --git a/apps/labrinth/.sqlx/query-5894d30f04a5413a607f2417cd3b7a9710aeadd5f97e3c25521202e9e21e261e.json b/apps/labrinth/.sqlx/query-5894d30f04a5413a607f2417cd3b7a9710aeadd5f97e3c25521202e9e21e261e.json new file mode 100644 index 0000000000..43beec0308 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5894d30f04a5413a607f2417cd3b7a9710aeadd5f97e3c25521202e9e21e261e.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating\n\t\t\tFROM moderation_notes\n\t\t\tWHERE organization_id = ANY($1)\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "organization_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "last_modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_author", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "notes", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "user_rating", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + true, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5894d30f04a5413a607f2417cd3b7a9710aeadd5f97e3c25521202e9e21e261e" +} diff --git a/apps/labrinth/.sqlx/query-5a86ade76259aafdd5b778e5b1909e1653c4442d9ace4105dd18aa5333c52aa2.json b/apps/labrinth/.sqlx/query-5a86ade76259aafdd5b778e5b1909e1653c4442d9ace4105dd18aa5333c52aa2.json new file mode 100644 index 0000000000..d167bd309c --- /dev/null +++ b/apps/labrinth/.sqlx/query-5a86ade76259aafdd5b778e5b1909e1653c4442d9ace4105dd18aa5333c52aa2.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE moderation_notes\n SET\n last_modified = NOW(),\n last_author = $1,\n version = version + 1,\n notes = COALESCE($2::text, notes),\n user_rating = COALESCE($3::integer, user_rating)\n WHERE (\n ($4::bigint IS NOT NULL AND user_id = $4) OR\n ($5::bigint IS NOT NULL AND organization_id = $5)\n )\n AND version = $6\n RETURNING version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int4", + "Int8", + "Int8", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5a86ade76259aafdd5b778e5b1909e1653c4442d9ace4105dd18aa5333c52aa2" +} diff --git a/apps/labrinth/.sqlx/query-ad49878323e942bdbaac87c351621e51871ec591bc0e7ff29119834e3ed8bf53.json b/apps/labrinth/.sqlx/query-ad49878323e942bdbaac87c351621e51871ec591bc0e7ff29119834e3ed8bf53.json new file mode 100644 index 0000000000..97e91ba779 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ad49878323e942bdbaac87c351621e51871ec591bc0e7ff29119834e3ed8bf53.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT user_id, organization_id, last_modified, created_at, last_author, version, notes, user_rating\n\t\t\tFROM moderation_notes\n\t\t\tWHERE user_id = ANY($1)\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "organization_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "last_modified", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_author", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "notes", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "user_rating", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + true, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "ad49878323e942bdbaac87c351621e51871ec591bc0e7ff29119834e3ed8bf53" +} From 4b2eee80d8c7d8ead92a7dee774675b79bade2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 17:56:04 -0400 Subject: [PATCH 05/15] Accept missing If-Match if no existing note --- .../src/models/v3/moderation_notes.rs | 17 ++++++----- apps/labrinth/src/routes/v3/organizations.rs | 30 +++++++++++-------- apps/labrinth/src/routes/v3/users.rs | 30 +++++++++++-------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/apps/labrinth/src/models/v3/moderation_notes.rs b/apps/labrinth/src/models/v3/moderation_notes.rs index 2926b65084..8e972a1379 100644 --- a/apps/labrinth/src/models/v3/moderation_notes.rs +++ b/apps/labrinth/src/models/v3/moderation_notes.rs @@ -46,12 +46,12 @@ impl PatchModerationNote { } } -pub fn parse_if_match_header(req: &HttpRequest) -> Result { - let value = req.headers().get(IF_MATCH).ok_or_else(|| { - ApiError::PreconditionRequired( - "`if-match` header is required".to_string(), - ) - })?; +pub fn parse_if_match_header( + req: &HttpRequest, +) -> Result, ApiError> { + let Some(value) = req.headers().get(IF_MATCH) else { + return Ok(None); + }; let value = value.to_str().map_err(|_| { ApiError::InvalidInput( @@ -59,9 +59,10 @@ pub fn parse_if_match_header(req: &HttpRequest) -> Result { ) })?; - value.parse::().map_err(|_| { + Some(value.parse::().map_err(|_| { ApiError::InvalidInput( "`if-match` header must be a valid integer".to_string(), ) - }) + })) + .transpose() } diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 783b2c61c6..9c5a59c3e2 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -334,34 +334,40 @@ pub async fn organization_notes_edit( .ok_or(ApiError::NotFound)?; let mut transaction = pool.begin().await?; - let updated = if expected_version == 0 { - DBModerationNote::insert( + if let Some(expected) = expected_version { + let updated = DBModerationNote::update( None, Some(organization.id), user.id.into(), + expected, new_note.notes.as_deref(), new_note.user_rating, &mut transaction, ) - .await? + .await?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } } else { - DBModerationNote::update( + let updated = DBModerationNote::insert( None, Some(organization.id), user.id.into(), - expected_version, new_note.notes.as_deref(), new_note.user_rating, &mut transaction, ) - .await? - }; + .await?; - if updated.is_none() { - return Err(ApiError::PreconditionFailed( - "moderation note version does not match".to_string(), - )); - } + if updated.is_none() { + return Err(ApiError::PreconditionRequired( + "moderation note version does not match".to_string(), + )); + } + }; transaction.commit().await?; DBModerationNote::clear_organization_cache(organization.id, &redis).await?; diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index ab804fe0b2..e95b328272 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -308,34 +308,40 @@ pub async fn user_notes_edit( .ok_or(ApiError::NotFound)?; let mut transaction = pool.begin().await?; - let updated = if expected_version == 0 { - DBModerationNote::insert( + if let Some(expected) = expected_version { + let updated = DBModerationNote::update( Some(user_data.id), None, user.id.into(), + expected, new_note.notes.as_deref(), new_note.user_rating, &mut transaction, ) - .await? + .await?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } } else { - DBModerationNote::update( + let updated = DBModerationNote::insert( Some(user_data.id), None, user.id.into(), - expected_version, new_note.notes.as_deref(), new_note.user_rating, &mut transaction, ) - .await? - }; + .await?; - if updated.is_none() { - return Err(ApiError::PreconditionFailed( - "moderation note version does not match".to_string(), - )); - } + if updated.is_none() { + return Err(ApiError::PreconditionRequired( + "moderation note version does not match".to_string(), + )); + } + }; transaction.commit().await?; DBModerationNote::clear_user_cache(user_data.id, &redis).await?; From b760e9b1e2f7b74417a40962097f085cca254f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 18:00:03 -0400 Subject: [PATCH 06/15] Undo v2 compat changes --- apps/labrinth/src/routes/v2/users.rs | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 7585d2dddc..79f5bc9e6e 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -1,5 +1,4 @@ use crate::database::PgPool; -use crate::database::models::DBUser; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::notifications::Notification; @@ -79,19 +78,31 @@ pub struct UserIds { )] #[get("/users")] pub async fn users_get( + req: HttpRequest, web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user_ids = serde_json::from_str::>(&ids.ids)?; - let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?; + let response = v3::users::users_get( + req, + web::Query(v3::users::UserIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; - let legacy_users: Vec = users_data - .into_iter() - .map(crate::models::users::User::from) - .map(LegacyUser::from) - .collect(); - Ok(HttpResponse::Ok().json(legacy_users)) + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(users) => { + let legacy_users: Vec = + users.into_iter().map(LegacyUser::from).collect(); + Ok(HttpResponse::Ok().json(legacy_users)) + } + Err(response) => Ok(response), + } } /// Get a user by ID or username. From 381cc2ed69c3ab269fd9856c3ef0d75798273afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 18:14:22 -0400 Subject: [PATCH 07/15] Fix tests --- apps/labrinth/tests/moderation_notes.rs | 38 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/tests/moderation_notes.rs b/apps/labrinth/tests/moderation_notes.rs index 9d2643a2ee..561adc76b2 100644 --- a/apps/labrinth/tests/moderation_notes.rs +++ b/apps/labrinth/tests/moderation_notes.rs @@ -43,11 +43,13 @@ pub async fn moderation_notes_users() { test::TestRequest::patch() .uri(&format!("/v3/user/{USER_USER_ID}/notes")) .append_pat(MOD_USER_PAT) - .set_json(json!({ "notes": "first note" })) + .set_json( + json!({ "notes": "first note", "user_rating": 1 }), + ) .to_request(), ) .await; - assert_status!(&resp, StatusCode::PRECONDITION_REQUIRED); + assert_status!(&resp, StatusCode::NO_CONTENT); // OK without If-Match for the first patch let resp = api .call( @@ -78,7 +80,6 @@ pub async fn moderation_notes_users() { test::TestRequest::patch() .uri(&format!("/v3/user/{USER_USER_ID}/notes")) .append_pat(MOD_USER_PAT) - .append_header(("If-Match", "0")) .set_json(json!({ "notes": "first note", "user_rating": 2, @@ -86,7 +87,7 @@ pub async fn moderation_notes_users() { .to_request(), ) .await; - assert_status!(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::PRECONDITION_REQUIRED); // Needs If-Match moving forward let resp = api .call( @@ -99,7 +100,7 @@ pub async fn moderation_notes_users() { assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; assert_eq!(body["notes"]["notes"], "first note"); - assert_eq!(body["notes"]["user_rating"], 2); + assert_eq!(body["notes"]["user_rating"], 1); assert_eq!(body["notes"]["version"], 1); assert_eq!(body["notes"]["last_author"], "2"); @@ -206,6 +207,20 @@ pub async fn moderation_notes_organizations() { .to_request(), ) .await; + assert_status!(&resp, StatusCode::PRECONDITION_FAILED); // Shouldn't have If-Match for the first patch + + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/organization/{organization_id}/notes")) + .append_pat(MOD_USER_PAT) + .set_json(json!({ + "notes": "org note", + "user_rating": -1, + })) + .to_request(), + ) + .await; assert_status!(&resp, StatusCode::NO_CONTENT); let resp = api @@ -232,6 +247,19 @@ pub async fn moderation_notes_organizations() { .await; assert_status!(&resp, StatusCode::NO_CONTENT); + let resp = api + .call( + test::TestRequest::patch() + .uri(&format!("/v3/organization/{organization_id}/notes")) + .append_pat(MOD_USER_PAT) + .set_json(json!({ + "notes": "new note", + })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::PRECONDITION_REQUIRED); // Needs If-Match moving forward + let ids = serde_json::to_string(&vec![organization_id.as_str()]).unwrap(); let resp = api From 5a57250e72849709b595014edf887fc6400af018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 18:18:07 -0400 Subject: [PATCH 08/15] Remove CONSTRAINT CHECK on moderation_notes --- .../labrinth/migrations/20260513120000_moderation_notes.sql | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/labrinth/migrations/20260513120000_moderation_notes.sql b/apps/labrinth/migrations/20260513120000_moderation_notes.sql index f14d1374c5..1980018675 100644 --- a/apps/labrinth/migrations/20260513120000_moderation_notes.sql +++ b/apps/labrinth/migrations/20260513120000_moderation_notes.sql @@ -6,11 +6,7 @@ CREATE TABLE moderation_notes ( last_author BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, version INTEGER NOT NULL DEFAULT 0, notes TEXT NOT NULL, - user_rating INTEGER NOT NULL DEFAULT 0, - CONSTRAINT moderation_notes_one_target CHECK ( - (user_id IS NOT NULL AND organization_id IS NULL) - OR (user_id IS NULL AND organization_id IS NOT NULL) - ) + user_rating INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX moderation_notes_user_id_unique From 218979eabf09b901432a1ba6d4344f196fef1f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 18:20:53 -0400 Subject: [PATCH 09/15] Respect 1-indexing on moderation_notes.version default in DB migration --- apps/labrinth/migrations/20260513120000_moderation_notes.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/migrations/20260513120000_moderation_notes.sql b/apps/labrinth/migrations/20260513120000_moderation_notes.sql index 1980018675..d4e3fad340 100644 --- a/apps/labrinth/migrations/20260513120000_moderation_notes.sql +++ b/apps/labrinth/migrations/20260513120000_moderation_notes.sql @@ -4,7 +4,7 @@ CREATE TABLE moderation_notes ( last_modified TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, last_author BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - version INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, notes TEXT NOT NULL, user_rating INTEGER NOT NULL DEFAULT 0 ); From 69e312c53c11ee89ed865241e9a8bb1bc3d8705e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 19:51:06 -0400 Subject: [PATCH 10/15] Remove double Option --- apps/labrinth/src/models/v3/organizations.rs | 2 +- apps/labrinth/src/models/v3/users.rs | 2 +- apps/labrinth/src/routes/v3/organizations.rs | 4 ++-- apps/labrinth/src/routes/v3/users.rs | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index dcec44df3c..86d1a04822 100644 --- a/apps/labrinth/src/models/v3/organizations.rs +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -25,7 +25,7 @@ pub struct Organization { /// A list of the members of the organization pub members: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option>, + pub notes: Option, } impl Organization { diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 39769db22d..7575460a20 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -66,7 +66,7 @@ pub struct User { pub stripe_customer_id: Option, pub allow_friend_requests: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option>, + pub notes: Option, // DEPRECATED. Always returns None pub github_id: Option, diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 9c5a59c3e2..9489075b64 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -293,7 +293,7 @@ pub async fn organization_get( &redis, ) .await?; - organization.notes = Some(note.map(Into::into)); + organization.notes = note.map(Into::into); } return Ok(HttpResponse::Ok().json(organization)); } @@ -475,7 +475,7 @@ pub async fn organizations_get( let mut organization = models::organizations::Organization::from(data, team_members); if include_notes { - organization.notes = Some(notes.remove(&data_id).map(Into::into)); + organization.notes = notes.remove(&data_id).map(Into::into); } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index e95b328272..867a4d3912 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -174,7 +174,7 @@ pub async fn user_auth_get( if user.role.is_mod() { let note = DBModerationNote::get_user(user.id.into(), &**pool, &redis).await?; - user.notes = Some(note.map(Into::into)); + user.notes = note.map(Into::into); } Ok(HttpResponse::Ok().json(user)) @@ -223,7 +223,7 @@ pub async fn users_get( .map(|data| { let mut user = crate::models::users::User::from(data.clone()); if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { - user.notes = Some(notes.remove(&data.id).map(Into::into)); + user.notes = notes.remove(&data.id).map(Into::into); } user }) @@ -266,7 +266,7 @@ pub async fn user_get( if is_mod { let note = DBModerationNote::get_user(user_id, &**pool, &redis).await?; - response.notes = Some(note.map(Into::into)); + response.notes = note.map(Into::into); } Ok(HttpResponse::Ok().json(response)) From 32ff735dbc61133215e7157cf5c802b6fb3e12f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 19:51:57 -0400 Subject: [PATCH 11/15] .body("") -> .finish() --- apps/labrinth/src/routes/v3/organizations.rs | 2 +- apps/labrinth/src/routes/v3/users.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 9489075b64..add5b2f9e3 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -372,7 +372,7 @@ pub async fn organization_notes_edit( transaction.commit().await?; DBModerationNote::clear_organization_cache(organization.id, &redis).await?; - Ok(HttpResponse::NoContent().body("")) + Ok(HttpResponse::NoContent().finish()) } #[derive(Deserialize)] diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 867a4d3912..b12d471fc2 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -346,7 +346,7 @@ pub async fn user_notes_edit( transaction.commit().await?; DBModerationNote::clear_user_cache(user_data.id, &redis).await?; - Ok(HttpResponse::NoContent().body("")) + Ok(HttpResponse::NoContent().finish()) } pub async fn collections_list( From 7883641dd606f288446df7f49c2be3ce0420b16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 19:53:41 -0400 Subject: [PATCH 12/15] .remove() -> .get().clone() --- apps/labrinth/src/routes/v3/organizations.rs | 2 +- apps/labrinth/src/routes/v3/users.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index add5b2f9e3..7d05470a52 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -475,7 +475,7 @@ pub async fn organizations_get( let mut organization = models::organizations::Organization::from(data, team_members); if include_notes { - organization.notes = notes.remove(&data_id).map(Into::into); + organization.notes = notes.get(&data_id).clone().map(Into::into); } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index b12d471fc2..c9b7c5f9b1 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -223,7 +223,7 @@ pub async fn users_get( .map(|data| { let mut user = crate::models::users::User::from(data.clone()); if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { - user.notes = notes.remove(&data.id).map(Into::into); + user.notes = notes.get(&data.id).clone().map(Into::into); } user }) From 77ae68979cba17b397971451cd48a9dcd55bf32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Wed, 13 May 2026 20:00:31 -0400 Subject: [PATCH 13/15] cloned --- apps/labrinth/src/routes/v3/organizations.rs | 4 ++-- apps/labrinth/src/routes/v3/users.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 7d05470a52..e7e7f28f6c 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -417,7 +417,7 @@ pub async fn organizations_get( .ok(); let user_id = current_user.as_ref().map(|x| x.id.into()); let include_notes = current_user.as_ref().is_some_and(|x| x.role.is_mod()); - let mut notes = if include_notes { + let notes = if include_notes { DBModerationNote::get_many_organizations( &organizations_data.iter().map(|x| x.id).collect::>(), &**pool, @@ -475,7 +475,7 @@ pub async fn organizations_get( let mut organization = models::organizations::Organization::from(data, team_members); if include_notes { - organization.notes = notes.get(&data_id).clone().map(Into::into); + organization.notes = notes.get(&data_id).cloned().map(Into::into); } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index c9b7c5f9b1..24349c3e0b 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -207,7 +207,7 @@ pub async fn users_get( .map(|x| x.1) .ok(); - let mut notes = if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + let notes = if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { DBModerationNote::get_many_users( &users_data.iter().map(|x| x.id).collect::>(), &**pool, @@ -223,7 +223,7 @@ pub async fn users_get( .map(|data| { let mut user = crate::models::users::User::from(data.clone()); if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { - user.notes = notes.get(&data.id).clone().map(Into::into); + user.notes = notes.get(&data.id).cloned().map(Into::into); } user }) From a03ca9c5007519f4424626cedbfcd21af915750f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Thu, 14 May 2026 12:25:53 -0400 Subject: [PATCH 14/15] Review comments --- apps/labrinth/src/models/v3/organizations.rs | 4 ++-- apps/labrinth/src/routes/v3/organizations.rs | 5 +++-- apps/labrinth/src/routes/v3/users.rs | 16 +++++----------- apps/labrinth/tests/moderation_notes.rs | 10 +++++----- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index 86d1a04822..94a6ca3761 100644 --- a/apps/labrinth/src/models/v3/organizations.rs +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -25,7 +25,7 @@ pub struct Organization { /// A list of the members of the organization pub members: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option, + pub moderation_notes: Option, } impl Organization { @@ -42,7 +42,7 @@ impl Organization { members: team_members, icon_url: data.icon_url, color: data.color, - notes: None, + moderation_notes: None, } } } diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index e7e7f28f6c..973e5473b5 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -293,7 +293,7 @@ pub async fn organization_get( &redis, ) .await?; - organization.notes = note.map(Into::into); + organization.moderation_notes = note.map(Into::into); } return Ok(HttpResponse::Ok().json(organization)); } @@ -475,7 +475,8 @@ pub async fn organizations_get( let mut organization = models::organizations::Organization::from(data, team_members); if include_notes { - organization.notes = notes.get(&data_id).cloned().map(Into::into); + organization.moderation_notes = + notes.get(&data_id).cloned().map(Into::into); } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 24349c3e0b..b118f961bc 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -5,8 +5,9 @@ use crate::database::PgPool; use crate::util::error::Context; use crate::{ auth::{ - checks::is_visible_organization, filter_visible_collections, - filter_visible_projects, get_user_from_headers, + check_is_moderator_from_headers, checks::is_visible_organization, + filter_visible_collections, filter_visible_projects, + get_user_from_headers, }, database::{ models::{DBModerationNote, DBUser}, @@ -283,21 +284,14 @@ pub async fn user_notes_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( + let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::SESSION_ACCESS, ) - .await? - .1; - - if !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "you do not have permission to edit moderation notes".to_string(), - )); - } + .await?; new_note.validate_not_empty()?; let expected_version = diff --git a/apps/labrinth/tests/moderation_notes.rs b/apps/labrinth/tests/moderation_notes.rs index 561adc76b2..20fa7f8b67 100644 --- a/apps/labrinth/tests/moderation_notes.rs +++ b/apps/labrinth/tests/moderation_notes.rs @@ -192,7 +192,7 @@ pub async fn moderation_notes_organizations() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert!(body.get("notes").unwrap().is_null()); + assert!(body.get("moderation_notes").unwrap().is_null()); let resp = api .call( @@ -233,7 +233,7 @@ pub async fn moderation_notes_organizations() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert!(body.get("notes").is_none()); + assert!(body.get("moderation_notes").is_none()); let resp = api .call( @@ -275,9 +275,9 @@ pub async fn moderation_notes_organizations() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert_eq!(body[0]["notes"]["notes"], "updated org note"); - assert_eq!(body[0]["notes"]["user_rating"], -1); - assert_eq!(body[0]["notes"]["version"], 2); + assert_eq!(body[0]["moderation_notes"]["notes"], "updated org note"); + assert_eq!(body[0]["moderation_notes"]["user_rating"], -1); + assert_eq!(body[0]["moderation_notes"]["version"], 2); }) .await; } From ea4e588ebe9782423d88132a46bdf28664621bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Thu, 14 May 2026 13:25:05 -0400 Subject: [PATCH 15/15] moderation_notes everywhere --- apps/labrinth/src/models/v3/organizations.rs | 2 +- apps/labrinth/src/models/v3/users.rs | 6 +++--- apps/labrinth/src/routes/v3/organizations.rs | 4 ++-- apps/labrinth/src/routes/v3/users.rs | 7 ++++--- apps/labrinth/tests/moderation_notes.rs | 22 ++++++++++---------- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index 94a6ca3761..aaa8c3a5e9 100644 --- a/apps/labrinth/src/models/v3/organizations.rs +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -25,7 +25,7 @@ pub struct Organization { /// A list of the members of the organization pub members: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub moderation_notes: Option, + pub moderation_notes: Option>, } impl Organization { diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 7575460a20..e779c8281a 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -66,7 +66,7 @@ pub struct User { pub stripe_customer_id: Option, pub allow_friend_requests: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option, + pub moderation_notes: Option>, // DEPRECATED. Always returns None pub github_id: Option, @@ -101,7 +101,7 @@ impl From for User { github_id: None, stripe_customer_id: None, allow_friend_requests: None, - notes: None, + moderation_notes: None, } } } @@ -154,7 +154,7 @@ impl User { }), stripe_customer_id: db_user.stripe_customer_id, allow_friend_requests: Some(db_user.allow_friend_requests), - notes: None, + moderation_notes: None, } } } diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 973e5473b5..838f47c9a4 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -293,7 +293,7 @@ pub async fn organization_get( &redis, ) .await?; - organization.moderation_notes = note.map(Into::into); + organization.moderation_notes = Some(note.map(Into::into)); } return Ok(HttpResponse::Ok().json(organization)); } @@ -476,7 +476,7 @@ pub async fn organizations_get( models::organizations::Organization::from(data, team_members); if include_notes { organization.moderation_notes = - notes.get(&data_id).cloned().map(Into::into); + Some(notes.get(&data_id).cloned().map(Into::into)); } organizations.push(organization); } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index b118f961bc..c93d1b5336 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -175,7 +175,7 @@ pub async fn user_auth_get( if user.role.is_mod() { let note = DBModerationNote::get_user(user.id.into(), &**pool, &redis).await?; - user.notes = note.map(Into::into); + user.moderation_notes = Some(note.map(Into::into)); } Ok(HttpResponse::Ok().json(user)) @@ -224,7 +224,8 @@ pub async fn users_get( .map(|data| { let mut user = crate::models::users::User::from(data.clone()); if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { - user.notes = notes.get(&data.id).cloned().map(Into::into); + user.moderation_notes = + Some(notes.get(&data.id).cloned().map(Into::into)); } user }) @@ -267,7 +268,7 @@ pub async fn user_get( if is_mod { let note = DBModerationNote::get_user(user_id, &**pool, &redis).await?; - response.notes = note.map(Into::into); + response.moderation_notes = Some(note.map(Into::into)); } Ok(HttpResponse::Ok().json(response)) diff --git a/apps/labrinth/tests/moderation_notes.rs b/apps/labrinth/tests/moderation_notes.rs index 20fa7f8b67..28194dcc9b 100644 --- a/apps/labrinth/tests/moderation_notes.rs +++ b/apps/labrinth/tests/moderation_notes.rs @@ -24,7 +24,7 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert!(body.get("notes").unwrap().is_null()); + assert!(body.get("moderation_notes").unwrap().is_null()); let resp = api .call( @@ -36,7 +36,7 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert!(body.get("notes").is_none()); + assert!(body.get("moderation_notes").is_none()); let resp = api .call( @@ -99,10 +99,10 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert_eq!(body["notes"]["notes"], "first note"); - assert_eq!(body["notes"]["user_rating"], 1); - assert_eq!(body["notes"]["version"], 1); - assert_eq!(body["notes"]["last_author"], "2"); + assert_eq!(body["moderation_notes"]["notes"], "first note"); + assert_eq!(body["moderation_notes"]["user_rating"], 1); + assert_eq!(body["moderation_notes"]["version"], 1); + assert_eq!(body["moderation_notes"]["last_author"], "2"); let resp = api .call( @@ -138,9 +138,9 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert_eq!(body["notes"]["notes"], "first note"); - assert_eq!(body["notes"]["user_rating"], 4); - assert_eq!(body["notes"]["version"], 2); + assert_eq!(body["moderation_notes"]["notes"], "first note"); + assert_eq!(body["moderation_notes"]["user_rating"], 4); + assert_eq!(body["moderation_notes"]["version"], 2); let user_ids = serde_json::to_string(&vec![USER_USER_ID]).unwrap(); let resp = api @@ -156,7 +156,7 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert_eq!(body[0]["notes"]["version"], 2); + assert_eq!(body[0]["moderation_notes"]["version"], 2); let resp = api .call( @@ -170,7 +170,7 @@ pub async fn moderation_notes_users() { .await; assert_status!(&resp, StatusCode::OK); let body: Value = test::read_body_json(resp).await; - assert!(body[0].get("notes").is_none()); + assert!(body[0].get("moderation_notes").is_none()); }) .await; }