mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
Add moderator notes to users & organizations (#6094)
* Moderator notes
* Use macros
* Improve queries
* Query cache
* Accept missing If-Match if no existing note
* Undo v2 compat changes
* Fix tests
* Remove CONSTRAINT CHECK on moderation_notes
* Respect 1-indexing on moderation_notes.version default in DB migration
* Remove double Option
* .body("") -> .finish()
* .remove() -> .get().clone()
* cloned
* Review comments
* moderation_notes everywhere
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<DBUserId>,
|
||||
pub organization_id: Option<DBOrganizationId>,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
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<HashMap<DBUserId, Self>, DatabaseError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let ids = user_ids
|
||||
.iter()
|
||||
.map(|id| id.0.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let cached = {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.get_many_deserialized_from_json::<Self>(
|
||||
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<Option<Self>, 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<HashMap<DBOrganizationId, Self>, DatabaseError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let ids = organization_ids
|
||||
.iter()
|
||||
.map(|id| id.0.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let cached = {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.get_many_deserialized_from_json::<Self>(
|
||||
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<Option<Self>, 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<DBUserId>,
|
||||
organization_id: Option<DBOrganizationId>,
|
||||
last_author: DBUserId,
|
||||
notes: Option<&str>,
|
||||
user_rating: Option<i32>,
|
||||
exec: E,
|
||||
) -> Result<Option<i32>, 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<DBUserId>,
|
||||
organization_id: Option<DBOrganizationId>,
|
||||
last_author: DBUserId,
|
||||
expected_current_version: i32,
|
||||
notes: Option<&str>,
|
||||
user_rating: Option<i32>,
|
||||
exec: E,
|
||||
) -> Result<Option<i32>, 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_author: UserId,
|
||||
pub user_rating: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
impl From<crate::database::models::DBModerationNote> 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<String>,
|
||||
pub user_rating: Option<i32>,
|
||||
}
|
||||
|
||||
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<Option<i32>, 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::<i32>().map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"`if-match` header must be a valid integer".to_string(),
|
||||
)
|
||||
}))
|
||||
.transpose()
|
||||
}
|
||||
@@ -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<TeamMember>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub moderation_notes: Option<Option<ModerationNote>>,
|
||||
}
|
||||
|
||||
impl Organization {
|
||||
@@ -39,6 +42,7 @@ impl Organization {
|
||||
members: team_members,
|
||||
icon_url: data.icon_url,
|
||||
color: data.color,
|
||||
moderation_notes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserPayoutData>,
|
||||
pub stripe_customer_id: Option<String>,
|
||||
pub allow_friend_requests: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub moderation_notes: Option<Option<ModerationNote>>,
|
||||
|
||||
// DEPRECATED. Always returns None
|
||||
pub github_id: Option<u64>,
|
||||
@@ -98,6 +101,7 @@ impl From<DBUser> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,14 +78,18 @@ pub struct UserIds {
|
||||
)]
|
||||
#[get("/users")]
|
||||
pub async fn users_get(
|
||||
req: HttpRequest,
|
||||
web::Query(ids): web::Query<UserIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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)?;
|
||||
|
||||
@@ -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<crate::models::moderation_notes::PatchModerationNote>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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::<Vec<_>>(),
|
||||
&**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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_ids = serde_json::from_str::<Vec<String>>(&ids.ids)?;
|
||||
|
||||
let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?;
|
||||
|
||||
let users: Vec<crate::models::users::User> =
|
||||
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::<Vec<_>>(),
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let users: Vec<crate::models::users::User> = 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<crate::models::moderation_notes::PatchModerationNote>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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,)>,
|
||||
|
||||
Reference in New Issue
Block a user