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:
François-Xavier Talbot
2026-05-16 12:30:36 -04:00
committed by GitHub
parent cee942dcef
commit b72bc18a6b
17 changed files with 1119 additions and 14 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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);
+2
View File
@@ -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,
&note,
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,
&note,
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
}
}
+1
View File
@@ -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;
+1
View File
@@ -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,
}
}
}
+5
View File
@@ -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,
}
}
}
+8
View File
@@ -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,
+4
View File
@@ -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)?;
+104 -3
View File
@@ -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);
}
+131 -11
View File
@@ -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,)>,
+283
View File
@@ -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;
}