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" +} diff --git a/apps/labrinth/migrations/20260513120000_moderation_notes.sql b/apps/labrinth/migrations/20260513120000_moderation_notes.sql new file mode 100644 index 0000000000..d4e3fad340 --- /dev/null +++ b/apps/labrinth/migrations/20260513120000_moderation_notes.sql @@ -0,0 +1,21 @@ +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 1, + notes TEXT NOT NULL, + user_rating INTEGER NOT NULL DEFAULT 0 +); + +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..3df5c8c855 --- /dev/null +++ b/apps/labrinth/src/database/models/moderation_note_item.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +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 { + 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) + "#, + &missing_ids, + ) + .fetch_all(exec) + .await?; + + let mut redis = redis.connect().await?; + for row in rows { + 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 + .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) + "#, + &missing_ids, + ) + .fetch_all(exec) + .await?; + + let mut redis = redis.connect().await?; + for row in rows { + 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 + .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 insert<'a, E>( + user_id: Option, + organization_id: Option, + last_author: DBUserId, + notes: Option<&str>, + user_rating: Option, + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query_scalar!( + r#" + 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(result) + } + + pub async fn update<'a, E>( + user_id: Option, + organization_id: Option, + last_author: DBUserId, + expected_current_version: i32, + notes: Option<&str>, + user_rating: Option, + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query_scalar!( + r#" + 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(result) + } + + 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..8e972a1379 --- /dev/null +++ b/apps/labrinth/src/models/v3/moderation_notes.rs @@ -0,0 +1,68 @@ +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, ApiError> { + let Some(value) = req.headers().get(IF_MATCH) else { + return Ok(None); + }; + + let value = value.to_str().map_err(|_| { + ApiError::InvalidInput( + "`if-match` header must be a valid integer".to_string(), + ) + })?; + + Some(value.parse::().map_err(|_| { + ApiError::InvalidInput( + "`if-match` header must be a valid integer".to_string(), + ) + })) + .transpose() +} diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index 4ec48cdc36..aaa8c3a5e9 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 moderation_notes: Option>, } impl Organization { @@ -39,6 +42,7 @@ impl Organization { members: team_members, icon_url: data.icon_url, color: data.color, + moderation_notes: None, } } } diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 0f276fc8fa..e779c8281a 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 moderation_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, + moderation_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), + moderation_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..79f5bc9e6e 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -78,14 +78,18 @@ 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 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)?; diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 890c6a4e42..838f47c9a4 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,97 @@ 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.moderation_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?; + 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?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } + } else { + let updated = DBModerationNote::insert( + None, + Some(organization.id), + user.id.into(), + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await?; + + 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?; + + Ok(HttpResponse::NoContent().finish()) +} + #[derive(Deserialize)] pub struct OrganizationIds { pub ids: String, @@ -331,6 +416,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 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 +471,13 @@ 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.moderation_notes = + 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 708393cbed..c93d1b5336 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -5,10 +5,14 @@ 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}, + redis::RedisPool, }, - database::{models::DBUser, redis::RedisPool}, file_hosting::{FileHost, FileHostPublicity}, models::{ notifications::Notification, @@ -35,6 +39,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 +172,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.moderation_notes = Some(note.map(Into::into)); + } + Ok(HttpResponse::Ok().json(user)) } @@ -176,16 +187,49 @@ 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 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.moderation_notes = + Some(notes.get(&data.id).cloned().map(Into::into)); + } + user + }) + .collect(); Ok(HttpResponse::Ok().json(users)) } @@ -211,12 +255,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.moderation_notes = Some(note.map(Into::into)); + } Ok(HttpResponse::Ok().json(response)) } else { @@ -224,6 +277,73 @@ 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 = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await?; + + 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?; + 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?; + + if updated.is_none() { + return Err(ApiError::PreconditionFailed( + "moderation note version does not match".to_string(), + )); + } + } else { + let updated = DBModerationNote::insert( + Some(user_data.id), + None, + user.id.into(), + new_note.notes.as_deref(), + new_note.user_rating, + &mut transaction, + ) + .await?; + + 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?; + + Ok(HttpResponse::NoContent().finish()) +} + 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..28194dcc9b --- /dev/null +++ b/apps/labrinth/tests/moderation_notes.rs @@ -0,0 +1,283 @@ +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("moderation_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("moderation_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", "user_rating": 1 }), + ) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); // OK without If-Match for the first patch + + 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) + .set_json(json!({ + "notes": "first note", + "user_rating": 2, + })) + .to_request(), + ) + .await; + assert_status!(&resp, StatusCode::PRECONDITION_REQUIRED); // Needs If-Match moving forward + + 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["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( + 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["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 + .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]["moderation_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("moderation_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("moderation_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::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 + .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("moderation_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 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 + .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]["moderation_notes"]["notes"], "updated org note"); + assert_eq!(body[0]["moderation_notes"]["user_rating"], -1); + assert_eq!(body[0]["moderation_notes"]["version"], 2); + }) + .await; +}