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 16:30:36 +00:00
committed by GitHub
parent cee942dcef
commit b72bc18a6b
17 changed files with 1119 additions and 14 deletions
+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,
}
}
}