From bdc0b7236b8a5dc634a775fca8d6cc32738db0db Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:46:54 +0400 Subject: [PATCH 01/25] initial thread issue model --- .../20260916120000_thread_issues.sql | 13 ++ apps/labrinth/src/database/models/ids.rs | 6 +- apps/labrinth/src/database/models/mod.rs | 2 +- .../src/database/models/report_item.rs | 15 +- .../src/database/models/thread_item.rs | 91 ++++++++---- apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v3/ids.rs | 2 + apps/labrinth/src/models/v3/mod.rs | 1 + apps/labrinth/src/models/v3/thread_issues.rs | 138 ++++++++++++++++++ .../src/routes/v3/project_creation.rs | 7 +- 10 files changed, 236 insertions(+), 40 deletions(-) create mode 100644 apps/labrinth/migrations/20260916120000_thread_issues.sql create mode 100644 apps/labrinth/src/models/v3/thread_issues.rs diff --git a/apps/labrinth/migrations/20260916120000_thread_issues.sql b/apps/labrinth/migrations/20260916120000_thread_issues.sql new file mode 100644 index 00000000000..26e5efa4d07 --- /dev/null +++ b/apps/labrinth/migrations/20260916120000_thread_issues.sql @@ -0,0 +1,13 @@ +CREATE TABLE threads_issues ( + id BIGINT PRIMARY KEY, + thread_id BIGINT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + what JSONB NOT NULL, + why JSONB NOT NULL, + user_addressed BOOLEAN NOT NULL DEFAULT FALSE, + moderator_verified BOOLEAN NOT NULL DEFAULT FALSE, + verdict VARCHAR(64) NOT NULL +); + +CREATE INDEX threads_issues_thread_id_verdict + ON threads_issues(thread_id, verdict); diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index bd953e7e2d2..a06ef5f34e9 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -6,7 +6,7 @@ use crate::models::ids::{ OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId, - ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, + ThreadId, ThreadIssueId, ThreadMessageId, UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -249,6 +249,10 @@ db_id_interface!( ThreadId, generator: generate_thread_id @ "threads", ); +db_id_interface!( + ThreadIssueId, + generator: generate_thread_issue_id @ "threads_issues", +); db_id_interface!( ThreadMessageId, generator: generate_thread_message_id @ "threads_messages", diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index e647428669a..fb7d4b958db 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -59,7 +59,7 @@ pub use project_disclosure_item::DBProjectDisclosure; pub use project_item::DBProject; pub use team_item::DBTeam; pub use team_item::DBTeamMember; -pub use thread_item::{DBThread, DBThreadMessage}; +pub use thread_item::{DBThread, DBThreadIssue, DBThreadMessage}; pub use user_item::DBUser; pub use version_item::DBVersion; diff --git a/apps/labrinth/src/database/models/report_item.rs b/apps/labrinth/src/database/models/report_item.rs index 6e748d89f20..2c35a97c214 100644 --- a/apps/labrinth/src/database/models/report_item.rs +++ b/apps/labrinth/src/database/models/report_item.rs @@ -2,6 +2,7 @@ use crate::database::PgTransaction; use super::ids::*; use chrono::{DateTime, Utc}; +use eyre::WrapErr; pub struct DBReport { pub id: DBReportId, @@ -126,7 +127,7 @@ impl DBReport { pub async fn remove_full( id: DBReportId, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> eyre::Result> { let result = sqlx::query!( " SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1) @@ -134,7 +135,8 @@ impl DBReport { id as DBReportId ) .fetch_one(&mut *transaction) - .await?; + .await + .wrap_err("checking whether report exists")?; if !result.exists.unwrap_or(false) { return Ok(None); @@ -148,14 +150,16 @@ impl DBReport { id as DBReportId ) .fetch_optional(&mut *transaction) - .await?; + .await + .wrap_err("fetching report thread")?; if let Some(thread_id) = thread_id { crate::database::models::DBThread::remove_full( DBThreadId(thread_id.id), transaction, ) - .await?; + .await + .wrap_err("removing report thread")?; } sqlx::query!( @@ -165,7 +169,8 @@ impl DBReport { id as DBReportId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing report")?; Ok(Some(())) } diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs index 9796044ddba..05bb145c5ed 100644 --- a/apps/labrinth/src/database/models/thread_item.rs +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -1,8 +1,9 @@ use super::ids::*; use crate::database::PgTransaction; -use crate::database::models::DatabaseError; +use crate::models::thread_issues::{ThreadIssueTarget, ThreadIssueVerdict}; use crate::models::threads::{MessageBody, ThreadType}; use chrono::{DateTime, Utc}; +use eyre::{Result, WrapErr}; use serde::{Deserialize, Serialize}; pub struct ThreadBuilder { @@ -41,12 +42,28 @@ pub struct DBThreadMessage { pub hide_identity: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DBThreadIssue { + pub id: DBThreadIssueId, + pub thread_id: DBThreadId, + pub what: ThreadIssueTarget, + pub why: serde_json::Value, + pub user_addressed: bool, + pub moderator_verified: bool, + pub verdict: ThreadIssueVerdict, + pub created_at: DateTime, +} + impl ThreadMessageBuilder { pub async fn insert( &self, transaction: &mut PgTransaction<'_>, - ) -> Result { - let thread_message_id = generate_thread_message_id(transaction).await?; + ) -> Result { + let thread_message_id = generate_thread_message_id(transaction) + .await + .wrap_err("generating thread message ID")?; + let body = serde_json::value::to_value(self.body.clone()) + .wrap_err("serializing thread message body")?; sqlx::query!( " @@ -59,12 +76,13 @@ impl ThreadMessageBuilder { ", thread_message_id as DBThreadMessageId, self.author_id.map(|x| x.0), - serde_json::value::to_value(self.body.clone())?, + body, self.thread_id as DBThreadId, self.hide_identity ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("inserting thread message")?; Ok(thread_message_id) } @@ -74,8 +92,10 @@ impl ThreadBuilder { pub async fn insert( &self, transaction: &mut PgTransaction<'_>, - ) -> Result { - let thread_id = generate_thread_id(&mut *transaction).await?; + ) -> Result { + let thread_id = generate_thread_id(&mut *transaction) + .await + .wrap_err("generating thread ID")?; sqlx::query!( " INSERT INTO threads ( @@ -91,7 +111,8 @@ impl ThreadBuilder { self.report_id.map(|x| x.0), ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("inserting thread")?; let (thread_ids, members): (Vec<_>, Vec<_>) = self.members.iter().map(|m| (thread_id.0, m.0)).unzip(); @@ -106,29 +127,29 @@ impl ThreadBuilder { &members[..], ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("inserting thread members")?; Ok(thread_id) } } impl DBThread { - pub async fn get<'a, E>( - id: DBThreadId, - exec: E, - ) -> Result, sqlx::Error> + pub async fn get<'a, E>(id: DBThreadId, exec: E) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { - Self::get_many(&[id], exec) + Ok(Self::get_many(&[id], exec) .await - .map(|x| x.into_iter().next()) + .wrap_err("fetching thread")? + .into_iter() + .next()) } pub async fn get_many<'a, E>( thread_ids: &[DBThreadId], exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -167,7 +188,8 @@ impl DBThread { members: x.members.unwrap_or_default().into_iter().map(DBUserId).collect(), }) .try_collect::>() - .await?; + .await + .wrap_err("fetching threads")?; Ok(threads) } @@ -175,7 +197,7 @@ impl DBThread { pub async fn remove_full( id: DBThreadId, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> Result> { sqlx::query!( " DELETE FROM threads_messages @@ -184,7 +206,8 @@ impl DBThread { id as DBThreadId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread messages")?; sqlx::query!( " DELETE FROM threads_members @@ -193,7 +216,8 @@ impl DBThread { id as DBThreadId ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread members")?; sqlx::query!( " DELETE FROM threads @@ -202,7 +226,8 @@ impl DBThread { id as DBThreadId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread")?; Ok(Some(())) } @@ -212,19 +237,21 @@ impl DBThreadMessage { pub async fn get<'a, E>( id: DBThreadMessageId, exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - Self::get_many(&[id], exec) + Ok(Self::get_many(&[id], exec) .await - .map(|x| x.into_iter().next()) + .wrap_err("fetching thread message")? + .into_iter() + .next()) } pub async fn get_many<'a, E>( message_ids: &[DBThreadMessageId], exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { @@ -250,7 +277,8 @@ impl DBThreadMessage { hide_identity: x.hide_identity, }) .try_collect::>() - .await?; + .await + .wrap_err("fetching thread messages")?; Ok(messages) } @@ -259,7 +287,10 @@ impl DBThreadMessage { id: DBThreadMessageId, private: bool, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> Result> { + let body = serde_json::to_value(MessageBody::Deleted { private }) + .wrap_err("serializing deleted thread message body")?; + sqlx::query!( " UPDATE threads_messages @@ -267,11 +298,11 @@ impl DBThreadMessage { WHERE id = $1 ", id as DBThreadMessageId, - serde_json::to_value(MessageBody::Deleted { private }) - .unwrap_or(serde_json::json!({})) + body, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread message")?; Ok(Some(())) } diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index e21616f12ba..1f1489c1fed 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -20,5 +20,6 @@ pub use v3::projects; pub use v3::reports; pub use v3::sessions; pub use v3::teams; +pub use v3::thread_issues; pub use v3::threads; pub use v3::users; diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 292c253b7fb..9377b35583a 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -5,6 +5,7 @@ base62_id!(ChargeId); base62_id!(CampaignDonationId); base62_id!(CollectionId); base62_id!(FileId); +base62_id!(GalleryImageId); base62_id!(ImageId); base62_id!(NotificationId); base62_id!(OAuthAccessTokenId); @@ -22,6 +23,7 @@ base62_id!(SessionId); base62_id!(TeamId); base62_id!(TeamMemberId); base62_id!(ThreadId); +base62_id!(ThreadIssueId); base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); base62_id!(VersionId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index be3ce755f75..2fe50d39257 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -18,6 +18,7 @@ pub mod projects; pub mod reports; pub mod sessions; pub mod teams; +pub mod thread_issues; pub mod threads; pub mod user_limits; pub mod users; diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs new file mode 100644 index 00000000000..6d70ede29f6 --- /dev/null +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -0,0 +1,138 @@ +use crate::models::ids::{GalleryImageId, ThreadIssueId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Issue that a moderator has flagged on a project in its moderation thread. +/// +/// If a moderator has a specific, targeted, actionable piece of feedback on a +/// specific part of a project (e.g. the description, summary, a gallery image, +/// etc.), they can add an issue to the moderation thread which targets that +/// part specifically, along with one of an enumerated set of reasons for why +/// the issue was applied. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ThreadIssue { + pub id: ThreadIssueId, + pub created_at: DateTime, + /// What part of a project this issue applies to. + pub what: ThreadIssueTarget, + /// Why the issue was raised on this part. + /// + /// This is treated as an opaque JSON blob by the backend; it is up to the + /// frontend to define a schema for it, and to render/localize it properly. + pub why: serde_json::Value, + /// Has the user confirmed that they've seen this issue and (attempted to) + /// resolve it? + pub user_addressed: bool, + /// Has a moderator seen the user's resolution and explicitly marked it as + /// resolved? + /// + /// If a moderator marks this issue as verified, then it will locked to + /// [`ThreadIssueVerdict::Resolved`]. + pub moderator_verified: bool, + /// Has the user changed the affected project part since this issue was + /// added? + pub value_state: Option, + /// Final derived verdict of this issue. + pub verdict: ThreadIssueVerdict, +} + +/// What part of a project must change for a [`ThreadIssue`] to be resolved? +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum ThreadIssueTarget { + Title(TextTarget), + Slug(TextTarget), + Summary(TextTarget), + Description(TextTarget), + Url(TextTarget), + License { + license: TextTarget, + url: TextTarget, + }, + Icon { + original_url: Option, + }, + Tags { + original: Vec, + }, + Links { + links: HashMap, + }, + GalleryImage { + originals: HashMap, + }, + Acknowledge { + mode: ThreadIssueAcknowledgement, + }, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct TextTarget { + /// Original value of this field when the [`ThreadIssue`] was made. + pub original: String, + /// Moderator-proposed value for this field. + pub suggestion: Option, +} + +/// How should a user communicate that they've resolved a [`ThreadIssue`]? +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueAcknowledgement { + /// They must tick a checkbox. + Checkbox, + /// They must reply to the thread with an explanation message. + Reply, +} + +/// Has the user changed the project part affected by a [`ThreadIssue`] since +/// this issue was added? +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueValueState { + /// Current field value is the same as it was originally. + /// + /// It may have never been changed, or been changed multiple times, ending + /// up at the original value again. + SameAsOriginal, + /// Current field value is different to the original value. + DifferentToOriginal, + /// Current field value is equal to the moderator's proposed value. + SameAsSuggested, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueVerdict { + Open, + Addressed, + Resolved, +} + +impl ThreadIssue { + pub fn from( + data: crate::database::models::DBThreadIssue, + value_state: Option, + ) -> Self { + Self { + id: data.id.into(), + what: data.what, + why: data.why, + user_addressed: data.user_addressed, + moderator_verified: data.moderator_verified, + value_state, + verdict: data.verdict, + created_at: data.created_at, + } + } +} diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index ecb9967b366..e343d7e7f28 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -277,7 +277,7 @@ pub async fn undo_uploads( Ok(()) } -/// Create a project. +/// Create a project. #[utoipa::path( context_path = "/project", tag = "projects", @@ -364,7 +364,7 @@ pub async fn project_create_internal( result } -/// Create a project with a specific ID. +/// Create a project with a specific ID. /// /// This is a testing endpoint only accessible behind an admin key. #[utoipa::path( @@ -989,7 +989,8 @@ async fn project_create_inner( report_id: None, } .insert(&mut *transaction) - .await?; + .await + .map_err(models::DatabaseError::Internal)?; let loaders = project_builder .initial_versions From 76dde7a4f2cf6c0d2e2956f28a7358be1e2e4e36 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:22:14 +0400 Subject: [PATCH 02/25] logic for syncing a thread issue's verdict --- apps/labrinth/src/database/models/mod.rs | 4 +- .../src/database/models/project_item.rs | 5 +- .../src/database/models/thread_issue_item.rs | 100 ++++ .../src/database/models/thread_item.rs | 13 - apps/labrinth/src/models/v3/ids.rs | 15 +- apps/labrinth/src/models/v3/projects.rs | 6 +- apps/labrinth/src/models/v3/thread_issues.rs | 527 +++++++++++++++++- .../src/routes/v3/project_creation.rs | 2 + apps/labrinth/src/routes/v3/projects/mod.rs | 1 + 9 files changed, 649 insertions(+), 24 deletions(-) create mode 100644 apps/labrinth/src/database/models/thread_issue_item.rs diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index fb7d4b958db..2473a25d574 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -35,6 +35,7 @@ pub mod project_item; pub mod report_item; pub mod session_item; pub mod team_item; +pub mod thread_issue_item; pub mod thread_item; pub mod user_item; pub mod user_limits; @@ -59,7 +60,8 @@ pub use project_disclosure_item::DBProjectDisclosure; pub use project_item::DBProject; pub use team_item::DBTeam; pub use team_item::DBTeamMember; -pub use thread_item::{DBThread, DBThreadIssue, DBThreadMessage}; +pub use thread_issue_item::DBThreadIssue; +pub use thread_item::{DBThread, DBThreadMessage}; pub use user_item::DBUser; pub use version_item::DBVersion; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 7d6b46afa83..b2f24ec685f 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -66,6 +66,8 @@ impl LinkUrl { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DBGalleryItem { + #[serde(default)] + pub id: Option, pub image_url: String, pub raw_image_url: String, pub featured: bool, @@ -718,7 +720,7 @@ impl DBProject { let mods_gallery: DashMap> = sqlx::query!( " - SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering + SELECT DISTINCT mod_id, mg.id gallery_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering FROM mods_gallery mg INNER JOIN mods m ON mg.mod_id = m.id WHERE m.id = ANY($1) OR m.slug = ANY($2) @@ -730,6 +732,7 @@ impl DBProject { acc.entry(DBProjectId(m.mod_id)) .or_default() .push(DBGalleryItem { + id: Some(m.gallery_id), image_url: m.image_url, raw_image_url: m.raw_image_url, featured: m.featured.unwrap_or(false), diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs new file mode 100644 index 00000000000..d738c4057fb --- /dev/null +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -0,0 +1,100 @@ +use super::ids::{DBThreadId, DBThreadIssueId}; +use crate::database::PgTransaction; +use crate::models::projects::Project; +use crate::models::thread_issues::{ThreadIssueTarget, ThreadIssueVerdict}; +use chrono::{DateTime, Utc}; +use eyre::{Result, WrapErr, eyre}; +use serde::{Deserialize, Serialize}; +use sqlx::types::Json; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DBThreadIssue { + pub id: DBThreadIssueId, + pub thread_id: DBThreadId, + pub what: ThreadIssueTarget, + pub why: serde_json::Value, + pub user_addressed: bool, + pub moderator_verified: bool, + pub verdict: ThreadIssueVerdict, + pub created_at: DateTime, +} + +impl DBThreadIssue { + pub async fn sync_project_verdicts( + project: &Project, + transaction: &mut PgTransaction<'_>, + ) -> Result> { + let rows = sqlx::query!( + r#" + SELECT + id, + thread_id, + what AS "what: Json", + why, + user_addressed, + moderator_verified, + verdict, + created_at + FROM threads_issues + WHERE thread_id = $1 + ORDER BY id + FOR UPDATE + "#, + DBThreadId::from(project.thread_id) as DBThreadId, + ) + .fetch_all(&mut *transaction) + .await + .wrap_err( + "locking project thread issues for verdict synchronization", + )?; + + let mut issues = Vec::with_capacity(rows.len()); + for row in rows { + let stored_verdict = match row.verdict.as_str() { + "open" => ThreadIssueVerdict::Open, + "addressed" => ThreadIssueVerdict::Addressed, + "resolved" => ThreadIssueVerdict::Resolved, + verdict => { + return Err(eyre!( + "thread issue `{}` has invalid verdict `{verdict}`", + row.id + )); + } + }; + let what = row.what.0; + let verdict = what.verdict( + project, + row.user_addressed, + row.moderator_verified, + ); + + if verdict != stored_verdict { + sqlx::query!( + r#" + UPDATE threads_issues + SET verdict = $2 + WHERE id = $1 + "#, + row.id, + verdict.as_str(), + ) + .execute(&mut *transaction) + .await + .wrap_err("updating project thread issue verdict")?; + } + + issues.push(Self { + id: DBThreadIssueId(row.id), + thread_id: DBThreadId(row.thread_id), + what, + why: row.why, + user_addressed: row.user_addressed, + moderator_verified: row.moderator_verified, + verdict, + created_at: row.created_at, + }); + } + + Ok(issues) + } +} diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs index 05bb145c5ed..273cc3ae0cb 100644 --- a/apps/labrinth/src/database/models/thread_item.rs +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -1,6 +1,5 @@ use super::ids::*; use crate::database::PgTransaction; -use crate::models::thread_issues::{ThreadIssueTarget, ThreadIssueVerdict}; use crate::models::threads::{MessageBody, ThreadType}; use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr}; @@ -42,18 +41,6 @@ pub struct DBThreadMessage { pub hide_identity: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct DBThreadIssue { - pub id: DBThreadIssueId, - pub thread_id: DBThreadId, - pub what: ThreadIssueTarget, - pub why: serde_json::Value, - pub user_addressed: bool, - pub moderator_verified: bool, - pub verdict: ThreadIssueVerdict, - pub created_at: DateTime, -} - impl ThreadMessageBuilder { pub async fn insert( &self, diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 9377b35583a..9e39e93e92e 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -1,11 +1,24 @@ use ariadne::ids::base62_id; +#[derive( + Copy, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + Debug, + Hash, + utoipa::ToSchema, +)] +#[serde(transparent)] +pub struct GalleryImageId(pub u32); + base62_id!(AttributionGroupId); base62_id!(ChargeId); base62_id!(CampaignDonationId); base62_id!(CollectionId); base62_id!(FileId); -base62_id!(GalleryImageId); base62_id!(ImageId); base62_id!(NotificationId); base62_id!(OAuthAccessTokenId); diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index ee05942b6c1..b890d125867 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -6,7 +6,8 @@ use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; use crate::models::exp; use crate::models::ids::{ - FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, + FileId, GalleryImageId, OrganizationId, ProjectId, TeamId, ThreadId, + VersionId, }; use crate::routes::{FileHash, HashAlgorithm}; use ariadne::ids::UserId; @@ -205,6 +206,7 @@ impl From for Project { .gallery_items .into_iter() .map(|x| GalleryItem { + id: x.id.map(|id| GalleryImageId(id as u32)), url: x.image_url, raw_url: x.raw_image_url, featured: x.featured, @@ -381,6 +383,8 @@ impl Project { } #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct GalleryItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, pub url: String, pub raw_url: String, pub featured: bool, diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 6d70ede29f6..e630126707d 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -1,4 +1,5 @@ use crate::models::ids::{GalleryImageId, ThreadIssueId}; +use crate::models::projects::Project; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -32,7 +33,7 @@ pub struct ThreadIssue { pub moderator_verified: bool, /// Has the user changed the affected project part since this issue was /// added? - pub value_state: Option, + pub value_state: ThreadIssueValueState, /// Final derived verdict of this issue. pub verdict: ThreadIssueVerdict, } @@ -47,7 +48,6 @@ pub enum ThreadIssueTarget { Slug(TextTarget), Summary(TextTarget), Description(TextTarget), - Url(TextTarget), License { license: TextTarget, url: TextTarget, @@ -61,8 +61,8 @@ pub enum ThreadIssueTarget { Links { links: HashMap, }, - GalleryImage { - originals: HashMap, + RemoveGalleryImages { + originals: Vec<(GalleryImageId, String)>, }, Acknowledge { mode: ThreadIssueAcknowledgement, @@ -91,8 +91,7 @@ pub enum ThreadIssueAcknowledgement { Reply, } -/// Has the user changed the project part affected by a [`ThreadIssue`] since -/// this issue was added? +/// Current state of the action required to resolve a [`ThreadIssue`]. #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, )] @@ -119,10 +118,161 @@ pub enum ThreadIssueVerdict { Resolved, } +impl ThreadIssueVerdict { + pub fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Addressed => "addressed", + Self::Resolved => "resolved", + } + } +} + +impl ThreadIssueTarget { + pub fn verdict( + &self, + project: &Project, + user_addressed: bool, + moderator_verified: bool, + ) -> ThreadIssueVerdict { + if moderator_verified { + return ThreadIssueVerdict::Resolved; + } + + match (self.value_state(project, user_addressed), user_addressed) { + (ThreadIssueValueState::SameAsSuggested, _) => { + ThreadIssueVerdict::Resolved + } + (ThreadIssueValueState::DifferentToOriginal, true) => { + ThreadIssueVerdict::Addressed + } + _ => ThreadIssueVerdict::Open, + } + } + + pub fn value_state( + &self, + project: &Project, + user_addressed: bool, + ) -> ThreadIssueValueState { + let state = match self { + Self::Title(target) => value_state(target, &project.name), + Self::Slug(target) => { + value_state(target, project.slug.as_deref().unwrap_or_default()) + } + Self::Summary(target) => value_state(target, &project.summary), + Self::Description(target) => { + value_state(target, &project.description) + } + Self::License { license, url } => { + let license_state = + value_state(license, project.license.id.as_str()); + let url_state = value_state( + url, + project.license.url.as_deref().unwrap_or_default(), + ); + + match (license_state, url_state) { + (ThreadIssueValueState::DifferentToOriginal, _) + | (_, ThreadIssueValueState::DifferentToOriginal) => { + ThreadIssueValueState::DifferentToOriginal + } + (ThreadIssueValueState::SameAsSuggested, _) + | (_, ThreadIssueValueState::SameAsSuggested) => { + ThreadIssueValueState::SameAsSuggested + } + _ => ThreadIssueValueState::SameAsOriginal, + } + } + Self::Icon { original_url } => { + if project.icon_url == *original_url { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::Tags { original } => { + let current = project + .categories + .iter() + .chain(&project.additional_categories) + .cloned() + .collect::>(); + if current == *original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::Links { links } => { + let mut state = ThreadIssueValueState::SameAsOriginal; + for (platform, target) in links { + let current = project + .link_urls + .get(platform) + .map(|link| link.url.as_str()) + .unwrap_or_default(); + match value_state(target, current) { + ThreadIssueValueState::DifferentToOriginal => { + state = ThreadIssueValueState::DifferentToOriginal; + break; + } + ThreadIssueValueState::SameAsSuggested => { + state = ThreadIssueValueState::SameAsSuggested; + } + ThreadIssueValueState::SameAsOriginal => {} + } + } + state + } + Self::RemoveGalleryImages { originals } => { + let remaining = originals + .iter() + .filter(|(id, _)| { + project + .gallery + .iter() + .any(|image| image.id.as_ref() == Some(id)) + }) + .count(); + + if remaining == 0 { + ThreadIssueValueState::SameAsSuggested + } else if remaining == originals.len() { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::Acknowledge { mode } => match (mode, user_addressed) { + (_, false) => ThreadIssueValueState::SameAsOriginal, + (ThreadIssueAcknowledgement::Checkbox, true) => { + ThreadIssueValueState::SameAsSuggested + } + (ThreadIssueAcknowledgement::Reply, true) => { + ThreadIssueValueState::DifferentToOriginal + } + }, + }; + + state + } +} + +fn value_state(target: &TextTarget, current: &str) -> ThreadIssueValueState { + if target.suggestion.as_deref() == Some(current) { + ThreadIssueValueState::SameAsSuggested + } else if current == target.original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } +} + impl ThreadIssue { pub fn from( data: crate::database::models::DBThreadIssue, - value_state: Option, + value_state: ThreadIssueValueState, ) -> Self { Self { id: data.id.into(), @@ -136,3 +286,366 @@ impl ThreadIssue { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::exp; + use crate::models::ids::{ProjectId, TeamId, ThreadId}; + use crate::models::projects::{ + GalleryItem, License, Link, MonetizationStatus, ProjectStatus, + SideTypesMigrationReviewStatus, + }; + + fn project() -> Project { + let now = Utc::now(); + Project { + id: ProjectId(1), + slug: Some("example-project".to_string()), + project_types: vec!["mod".to_string()], + games: vec!["minecraft".to_string()], + team_id: TeamId(2), + organization: None, + name: "Example Project".to_string(), + summary: "Example summary".to_string(), + description: "Example description".to_string(), + published: now, + updated: now, + approved: None, + queued: None, + status: ProjectStatus::Draft, + requested_status: None, + moderator_message: None, + license: License { + id: "MIT".to_string(), + name: "MIT License".to_string(), + url: Some("https://example.com/license".to_string()), + }, + downloads: 0, + followers: 0, + categories: vec!["technology".to_string()], + additional_categories: vec!["utility".to_string()], + loaders: vec!["fabric".to_string()], + versions: Vec::new(), + icon_url: Some("https://example.com/icon.png".to_string()), + raw_icon_url: None, + link_urls: HashMap::from([ + ( + "source".to_string(), + Link { + platform: "source".to_string(), + donation: false, + url: "https://example.com/source".to_string(), + }, + ), + ( + "issues".to_string(), + Link { + platform: "issues".to_string(), + donation: false, + url: "https://example.com/issues".to_string(), + }, + ), + ]), + gallery: vec![ + GalleryItem { + id: Some(GalleryImageId(1)), + url: "https://example.com/gallery/1".to_string(), + raw_url: "https://example.com/gallery/1/raw".to_string(), + featured: false, + name: None, + description: None, + created: now, + ordering: 0, + }, + GalleryItem { + id: Some(GalleryImageId(2)), + url: "https://example.com/gallery/2".to_string(), + raw_url: "https://example.com/gallery/2/raw".to_string(), + featured: false, + name: None, + description: None, + created: now, + ordering: 1, + }, + ], + color: None, + thread_id: ThreadId(3), + monetization_status: MonetizationStatus::Monetized, + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, + components: exp::ProjectQuery::default(), + fields: HashMap::new(), + } + } + + fn text_target(original: &str, suggestion: Option<&str>) -> TextTarget { + TextTarget { + original: original.to_string(), + suggestion: suggestion.map(str::to_string), + } + } + + #[test] + fn text_target_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::Title(text_target( + "Example Project", + Some("Suggested Project"), + )); + + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + + project.name = "Another Project".to_string(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.name = "Suggested Project".to_string(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsSuggested + ); + } + + #[test] + fn absent_slug_uses_an_empty_current_value() { + let mut project = project(); + project.slug = None; + let target = ThreadIssueTarget::Slug(text_target("", None)); + + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + } + + #[test] + fn license_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::License { + license: text_target("MIT", Some("Apache-2.0")), + url: text_target("https://example.com/license", None), + }; + + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + + project.license.id = "Apache-2.0".to_string(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsSuggested + ); + + project.license.url = + Some("https://example.com/other-license".to_string()); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn link_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::Links { + links: HashMap::from([ + ( + "source".to_string(), + text_target( + "https://example.com/source", + Some("https://example.com/new-source"), + ), + ), + ( + "issues".to_string(), + text_target("https://example.com/issues", None), + ), + ]), + }; + + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + + project.link_urls.get_mut("source").unwrap().url = + "https://example.com/new-source".to_string(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsSuggested + ); + + project.link_urls.get_mut("issues").unwrap().url = + "https://example.com/other-issues".to_string(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn icon_and_tag_value_states() { + let mut project = project(); + let icon = ThreadIssueTarget::Icon { + original_url: project.icon_url.clone(), + }; + let tags = ThreadIssueTarget::Tags { + original: vec!["technology".to_string(), "utility".to_string()], + }; + + assert_eq!( + icon.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + tags.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + + project.icon_url = None; + project.additional_categories.clear(); + assert_eq!( + icon.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + assert_eq!( + tags.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn all_gallery_targets_must_be_removed() { + let mut project = project(); + let target = ThreadIssueTarget::RemoveGalleryImages { + originals: vec![ + ( + GalleryImageId(1), + "https://example.com/gallery/1".to_string(), + ), + ( + GalleryImageId(2), + "https://example.com/gallery/2".to_string(), + ), + ], + }; + + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsOriginal + ); + + project.gallery.remove(0); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.gallery.clear(); + assert_eq!( + target.value_state(&project, false), + ThreadIssueValueState::SameAsSuggested + ); + } + + #[test] + fn acknowledgement_value_states() { + let checkbox = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Checkbox, + }; + let reply = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + + assert_eq!( + checkbox.value_state(&project(), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + checkbox.value_state(&project(), true), + ThreadIssueValueState::SameAsSuggested + ); + assert_eq!( + reply.value_state(&project(), true), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn verdict_tracks_resolution_actions() { + let mut project = project(); + let target = ThreadIssueTarget::Title(text_target( + "Example Project", + Some("Suggested Project"), + )); + + assert_eq!( + target.verdict(&project, true, false), + ThreadIssueVerdict::Open + ); + + project.name = "Another Project".to_string(); + assert_eq!( + target.verdict(&project, false, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + target.verdict(&project, true, false), + ThreadIssueVerdict::Addressed + ); + + project.name = "Suggested Project".to_string(); + assert_eq!( + target.verdict(&project, false, false), + ThreadIssueVerdict::Resolved + ); + + project.name = "Example Project".to_string(); + assert_eq!( + target.verdict(&project, false, true), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn gallery_and_acknowledgement_verdicts() { + let mut project = project(); + let gallery = ThreadIssueTarget::RemoveGalleryImages { + originals: vec![( + GalleryImageId(1), + "https://example.com/gallery/1".to_string(), + )], + }; + let checkbox = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Checkbox, + }; + let reply = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + + assert_eq!( + gallery.verdict(&project, true, false), + ThreadIssueVerdict::Addressed + ); + project.gallery.remove(0); + assert_eq!( + gallery.verdict(&project, false, false), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + checkbox.verdict(&project, true, false), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + reply.verdict(&project, true, false), + ThreadIssueVerdict::Addressed + ); + } +} diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index e343d7e7f28..ef8a7b925b4 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -680,6 +680,7 @@ async fn project_create_inner( publicity: FileHostPublicity::Public, }); gallery_urls.push(crate::models::projects::GalleryItem { + id: None, url: upload_result.url, raw_url: upload_result.raw_url, featured: item.featured, @@ -922,6 +923,7 @@ async fn project_create_inner( gallery_items: gallery_urls .iter() .map(|x| models::project_item::DBGalleryItem { + id: None, image_url: x.url.clone(), raw_image_url: x.raw_url.clone(), featured: x.featured, diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 5acae44de2e..b8e22c061e6 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -2705,6 +2705,7 @@ pub async fn add_gallery_item_internal( } let gallery_item = vec![db_models::project_item::DBGalleryItem { + id: None, image_url: upload_result.url.clone(), raw_image_url: upload_result.raw_url.clone(), featured: item.featured, From 64fc99364868d68b8f7ffe2a3589abc3c3e6e607 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:59:34 +0400 Subject: [PATCH 03/25] allow passing more context into thread issue eval --- .../src/database/models/thread_issue_item.rs | 11 +-- apps/labrinth/src/models/v3/thread_issues.rs | 87 +++++++++++-------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs index d738c4057fb..8c647961925 100644 --- a/apps/labrinth/src/database/models/thread_issue_item.rs +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -1,7 +1,8 @@ use super::ids::{DBThreadId, DBThreadIssueId}; use crate::database::PgTransaction; -use crate::models::projects::Project; -use crate::models::thread_issues::{ThreadIssueTarget, ThreadIssueVerdict}; +use crate::models::thread_issues::{ + ThreadIssueContext, ThreadIssueTarget, ThreadIssueVerdict, +}; use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr, eyre}; use serde::{Deserialize, Serialize}; @@ -21,7 +22,7 @@ pub struct DBThreadIssue { impl DBThreadIssue { pub async fn sync_project_verdicts( - project: &Project, + context: &ThreadIssueContext<'_>, transaction: &mut PgTransaction<'_>, ) -> Result> { let rows = sqlx::query!( @@ -40,7 +41,7 @@ impl DBThreadIssue { ORDER BY id FOR UPDATE "#, - DBThreadId::from(project.thread_id) as DBThreadId, + DBThreadId::from(context.project.thread_id) as DBThreadId, ) .fetch_all(&mut *transaction) .await @@ -63,7 +64,7 @@ impl DBThreadIssue { }; let what = row.what.0; let verdict = what.verdict( - project, + context, row.user_addressed, row.moderator_verified, ); diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index e630126707d..8acbd1228fd 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -1,9 +1,16 @@ use crate::models::ids::{GalleryImageId, ThreadIssueId}; -use crate::models::projects::Project; +use crate::models::projects::{Project, Version}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// Current project state used to evaluate moderation issues. +#[derive(Debug, Clone, Copy)] +pub struct ThreadIssueContext<'a> { + pub project: &'a Project, + pub versions: &'a [Version], +} + /// Issue that a moderator has flagged on a project in its moderation thread. /// /// If a moderator has a specific, targeted, actionable piece of feedback on a @@ -131,7 +138,7 @@ impl ThreadIssueVerdict { impl ThreadIssueTarget { pub fn verdict( &self, - project: &Project, + context: &ThreadIssueContext<'_>, user_addressed: bool, moderator_verified: bool, ) -> ThreadIssueVerdict { @@ -139,7 +146,7 @@ impl ThreadIssueTarget { return ThreadIssueVerdict::Resolved; } - match (self.value_state(project, user_addressed), user_addressed) { + match (self.value_state(context, user_addressed), user_addressed) { (ThreadIssueValueState::SameAsSuggested, _) => { ThreadIssueVerdict::Resolved } @@ -152,10 +159,11 @@ impl ThreadIssueTarget { pub fn value_state( &self, - project: &Project, + context: &ThreadIssueContext<'_>, user_addressed: bool, ) -> ThreadIssueValueState { - let state = match self { + let project = context.project; + match self { Self::Title(target) => value_state(target, &project.name), Self::Slug(target) => { value_state(target, project.slug.as_deref().unwrap_or_default()) @@ -253,9 +261,7 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } }, - }; - - state + } } } @@ -379,6 +385,13 @@ mod tests { } } + fn context(project: &Project) -> ThreadIssueContext<'_> { + ThreadIssueContext { + project, + versions: &[], + } + } + fn text_target(original: &str, suggestion: Option<&str>) -> TextTarget { TextTarget { original: original.to_string(), @@ -395,19 +408,19 @@ mod tests { )); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); project.name = "Another Project".to_string(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); project.name = "Suggested Project".to_string(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); } @@ -419,7 +432,7 @@ mod tests { let target = ThreadIssueTarget::Slug(text_target("", None)); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); } @@ -433,20 +446,20 @@ mod tests { }; assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); project.license.id = "Apache-2.0".to_string(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); project.license.url = Some("https://example.com/other-license".to_string()); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); } @@ -471,21 +484,21 @@ mod tests { }; assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); project.link_urls.get_mut("source").unwrap().url = "https://example.com/new-source".to_string(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); project.link_urls.get_mut("issues").unwrap().url = "https://example.com/other-issues".to_string(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); } @@ -501,22 +514,22 @@ mod tests { }; assert_eq!( - icon.value_state(&project, false), + icon.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); assert_eq!( - tags.value_state(&project, false), + tags.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); project.icon_url = None; project.additional_categories.clear(); assert_eq!( - icon.value_state(&project, false), + icon.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); assert_eq!( - tags.value_state(&project, false), + tags.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); } @@ -538,19 +551,19 @@ mod tests { }; assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal ); project.gallery.remove(0); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); project.gallery.clear(); assert_eq!( - target.value_state(&project, false), + target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); } @@ -565,15 +578,15 @@ mod tests { }; assert_eq!( - checkbox.value_state(&project(), false), + checkbox.value_state(&context(&project()), false), ThreadIssueValueState::SameAsOriginal ); assert_eq!( - checkbox.value_state(&project(), true), + checkbox.value_state(&context(&project()), true), ThreadIssueValueState::SameAsSuggested ); assert_eq!( - reply.value_state(&project(), true), + reply.value_state(&context(&project()), true), ThreadIssueValueState::DifferentToOriginal ); } @@ -587,29 +600,29 @@ mod tests { )); assert_eq!( - target.verdict(&project, true, false), + target.verdict(&context(&project), true, false), ThreadIssueVerdict::Open ); project.name = "Another Project".to_string(); assert_eq!( - target.verdict(&project, false, false), + target.verdict(&context(&project), false, false), ThreadIssueVerdict::Open ); assert_eq!( - target.verdict(&project, true, false), + target.verdict(&context(&project), true, false), ThreadIssueVerdict::Addressed ); project.name = "Suggested Project".to_string(); assert_eq!( - target.verdict(&project, false, false), + target.verdict(&context(&project), false, false), ThreadIssueVerdict::Resolved ); project.name = "Example Project".to_string(); assert_eq!( - target.verdict(&project, false, true), + target.verdict(&context(&project), false, true), ThreadIssueVerdict::Resolved ); } @@ -631,20 +644,20 @@ mod tests { }; assert_eq!( - gallery.verdict(&project, true, false), + gallery.verdict(&context(&project), true, false), ThreadIssueVerdict::Addressed ); project.gallery.remove(0); assert_eq!( - gallery.verdict(&project, false, false), + gallery.verdict(&context(&project), false, false), ThreadIssueVerdict::Resolved ); assert_eq!( - checkbox.verdict(&project, true, false), + checkbox.verdict(&context(&project), true, false), ThreadIssueVerdict::Resolved ); assert_eq!( - reply.verdict(&project, true, false), + reply.verdict(&context(&project), true, false), ThreadIssueVerdict::Addressed ); } From 8aa499c47ab81f55651b35872970496076029841 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:13:14 +0100 Subject: [PATCH 04/25] project mutation finalizer logic --- .../routes/internal/moderation/tech_review.rs | 41 ++- apps/labrinth/src/routes/v3/disclosures.rs | 28 +- apps/labrinth/src/routes/v3/organizations.rs | 75 ++--- .../src/routes/v3/project_creation.rs | 20 +- .../src/routes/v3/project_creation/new.rs | 17 +- apps/labrinth/src/routes/v3/projects/mod.rs | 312 ++++++------------ .../src/routes/v3/projects/mutation.rs | 210 ++++++++++++ .../src/routes/v3/projects/validate.rs | 66 +--- .../src/routes/v3/version_creation.rs | 18 +- apps/labrinth/src/routes/v3/version_file.rs | 20 +- apps/labrinth/src/routes/v3/versions.rs | 64 +--- 11 files changed, 428 insertions(+), 443 deletions(-) create mode 100644 apps/labrinth/src/routes/v3/projects/mutation.rs diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 017a417944e..9e5b071aa7f 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -221,7 +221,7 @@ pub struct GetIssue { pub include_hidden: bool, } -/// Get a Delphi report issue. +/// Get a Delphi report issue. #[utoipa::path( context_path = "/moderation/tech-review", tag = "moderation", @@ -1107,6 +1107,20 @@ pub async fn submit_report( .await .wrap_internal_err("failed to begin transaction")?; + sqlx::query!( + r#" + SELECT id + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + project_id as DBProjectId, + ) + .fetch_optional(&mut txn) + .await + .wrap_internal_err("locking project for technical review submission")? + .wrap_not_found_err("project not found")?; + let pending_issue_details = sqlx::query!( r#" SELECT @@ -1236,22 +1250,19 @@ pub async fn submit_report( .wrap_internal_err("failed to add tech review message")?; } - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; - if verdict == DelphiVerdict::Unsafe { - crate::routes::v3::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_id, - None, - None, + crate::routes::v3::projects::mutation::finalize_mutation( + project_id, txn, &redis, ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + .await?; + search_state + .queue + .push_project_change(project_id.into()) + .await; + } else { + txn.commit() + .await + .wrap_internal_err("failed to commit transaction")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 9923e7b36e0..a60ed147e70 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -7,14 +7,14 @@ use xredis::RedisPool; use crate::auth::checks::{is_team_member_project, is_visible_project}; use crate::auth::get_user_from_headers; -use crate::database::{DBProject, models as db_models}; +use crate::database::models as db_models; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::disclosures::{ DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureData, ProjectDisclosureType, }; use crate::models::pats::Scopes; -use crate::models::projects::ProjectStatus; + use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -280,24 +280,12 @@ pub async fn modify_project_disclosures( .wrap_internal_err("failed to remove project disclosure")?; } - if project.inner.status == ProjectStatus::Processing { - super::projects::validate::ensure_project_is_valid_for_review( - project.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("failed to commit project disclosure changes")?; - - DBProject::clear_cache(project.inner.id, project.inner.slug, None, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; + super::projects::mutation::finalize_mutation( + project.inner.id, + transaction, + &redis, + ) + .await?; search_state .queue diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 3ff46b06e31..3c5513278e9 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -863,10 +863,12 @@ pub async fn organization_delete( .await .wrap_internal_err("fetching query results from database")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutations( + &organization_project_ids, + transaction, + &redis, + ) + .await?; database::models::DBOrganization::clear_cache( organization.id, @@ -883,17 +885,10 @@ pub async fn organization_delete( } for project_id in organization_project_ids { - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_id, - None, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_id.into()) + .await; } if !organization_project_teams.is_empty() { @@ -1052,10 +1047,12 @@ pub async fn organization_projects_add( "querying database for `organization_projects_add`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + ) + .await?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], @@ -1069,17 +1066,10 @@ pub async fn organization_projects_add( ) .await .wrap_internal_err("clearing cached data from Redis")?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } else { return Err(ApiError::Auth(eyre::eyre!( "You do not have permission to add projects to this organization!", @@ -1262,10 +1252,12 @@ pub async fn organization_projects_remove( "querying database for `organization_projects_remove`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + ) + .await?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], &redis, @@ -1278,17 +1270,10 @@ pub async fn organization_projects_remove( ) .await .wrap_internal_err("clearing cached data from Redis")?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } else { return Err(ApiError::Auth(eyre::eyre!( "You do not have permission to add projects to this organization!", diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index ef8a7b925b4..e19469c2c10 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -350,15 +350,13 @@ pub async fn project_create_internal( return Err(e.into()); } } else { - transaction.commit().await?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::projects::mutation::finalize_mutation( project_id.into(), - None, - None, + transaction, + &redis, ) .await?; + search_state.queue.push_project_change(project_id).await; } result @@ -416,15 +414,13 @@ pub async fn project_create_with_id( return Err(e.into()); } } else { - transaction.commit().await?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::projects::mutation::finalize_mutation( project_id.into(), - None, - None, + transaction, + &redis, ) .await?; + search_state.queue.push_project_change(project_id).await; } result diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 29223757966..dd99794395e 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -110,7 +110,7 @@ pub struct ProjectCreate { pub components: exp::ProjectEdit, } -/// Create a project from components. +/// Create a project from components. /// /// Components must include `base` ([`exp::base::Project`]), and at least one /// other component. @@ -340,20 +340,13 @@ pub async fn create( .await .wrap_internal_err("failed to insert thread")?; - // and commit! - - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; - - super::super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::super::projects::mutation::finalize_mutation( project_id.into(), - Some(slug), - None, + txn, + &redis, ) .await?; + search_state.queue.push_project_change(project_id).await; Ok(web::Json(project_id)) } diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index b8e22c061e6..3885b76573c 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -52,6 +52,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; use xredis::RedisPool; +pub mod mutation; pub mod validate; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -501,30 +502,38 @@ pub async fn project_edit_internal( let submit_for_review = new_project.status == Some(ProjectStatus::Processing) && !user.role.is_mod(); - let leave_review = matches!( - new_project.status, - Some(ProjectStatus::Draft | ProjectStatus::Rejected) - ); - let validate_for_review = submit_for_review - || (project_item.inner.status == ProjectStatus::Processing - && !leave_review); - if submit_for_review { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::Auth(eyre!( - "you do not have permission to submit this project for review" - ))); - } - if project_item.inner.status.is_approved() { - return Err(ApiError::Auth(eyre!( - "you do not have permission to submit this project for review" - ))); - } + + if submit_for_review && !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::Auth(eyre!( + "you do not have permission to submit this project for review" + ))); } let mut transaction = pool .begin() .await .wrap_internal_err("starting database transaction")?; + let locked_project = sqlx::query!( + r#" + SELECT status, slug + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + id as db_ids::DBProjectId, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("locking project for editing")?; + project_item.inner.status = + ProjectStatus::from_string(&locked_project.status); + project_item.inner.slug = locked_project.slug; + + if submit_for_review && project_item.inner.status.is_approved() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to submit this project for review" + ))); + } if let Some(name) = &new_project.name { if !perms.contains(ProjectPermissions::EDIT_DETAILS) { @@ -1365,69 +1374,42 @@ pub async fn project_edit_internal( .await .wrap_api_err("deleting unused images")?; - if validate_for_review { - let reloaded_project = validate::ensure_project_is_valid_for_review( - id, - &pool, + if submit_for_review { + submit_project_for_review( + &project_item, + &user, + team_member.as_ref().is_none_or(|member| !member.accepted), + sync_archival_disclosure, &mut transaction, &redis, ) .await?; - - if submit_for_review { - submit_project_for_review( - &reloaded_project, - &user, - team_member.as_ref().is_none_or(|member| !member.accepted), - sync_archival_disclosure, - &mut transaction, - &redis, - ) - .await?; - } } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + mutation::finalize_project_edit( + id, + project_item.inner.status, + project_item.inner.slug.clone(), + transaction, + &redis, + ) + .await?; if became_unsearchable { - db_models::DBProject::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_removal(project_item.inner.id.into()) .await; } else if reindex_versions || became_searchable { - db_models::DBProject::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_with_all_versions_change(project_item.inner.id.into()) .await; } else { - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } Ok(HttpResponse::NoContent().body("")) @@ -2122,30 +2104,24 @@ pub async fn projects_edit( )); } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let changed_project_ids = changed_projects + .iter() + .map(|(project_id, _, _)| *project_id) + .collect::>(); + mutation::finalize_mutations(&changed_project_ids, transaction, &redis) + .await?; - for (project_id, slug, reindex_versions) in changed_projects { + for (project_id, _, reindex_versions) in changed_projects { if reindex_versions { - db_models::DBProject::clear_cache(project_id, slug, None, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_with_all_versions_change(project_id.into()) .await; } else { - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_id, - slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_id.into()) + .await; } } @@ -2379,19 +2355,12 @@ pub async fn project_icon_edit_internal( .await .wrap_internal_err("querying database for `project_icon_edit_internal`")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + mutation::finalize_mutation(project_item.inner.id, transaction, &redis) + .await?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -2509,19 +2478,12 @@ pub async fn delete_project_icon_internal( "querying database for `delete_project_icon_internal`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + mutation::finalize_mutation(project_item.inner.id, transaction, &redis) + .await?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -2722,44 +2684,12 @@ pub async fn add_gallery_item_internal( .await .wrap_internal_err("inserting galleries into database")?; - let validation_error = match project_item.inner.status { - ProjectStatus::Processing => { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await - .err() - } - _ => None, - }; - if let Some(error) = validation_error { - delete_old_images( - Some(upload_result.url), - Some(upload_result.raw_url), - FileHostPublicity::Public, - &**file_host, - ) - .await - .wrap_api_err("deleting rejected gallery image upload")?; - return Err(error); - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + mutation::finalize_mutation(project_item.inner.id, transaction, &redis) + .await?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -2985,30 +2915,12 @@ pub async fn edit_gallery_item_internal( )?; } - if project_item.inner.status == ProjectStatus::Processing { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) + mutation::finalize_mutation(project_item.inner.id, transaction, &redis) .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -3146,20 +3058,8 @@ pub async fn delete_gallery_item_internal( "querying database for `delete_gallery_item_internal`", )?; - if project_item.inner.status == ProjectStatus::Processing { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) + mutation::finalize_mutation(project_item.inner.id, transaction, &redis) .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; delete_old_images( Some(item.image_url), @@ -3170,15 +3070,10 @@ pub async fn delete_gallery_item_internal( .await .wrap_api_err("deleting old images")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -3268,6 +3163,21 @@ pub async fn project_delete_internal( .begin() .await .wrap_internal_err("failed to start transaction")?; + let locked_project = sqlx::query!( + r#" + SELECT status, slug + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + project.inner.id as db_ids::DBProjectId, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("locking project for deletion")?; + let original_status = ProjectStatus::from_string(&locked_project.status); + let original_slug = locked_project.slug; + delphi::tech_review_queue::remove_projects( &[project.inner.id], delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, @@ -3278,12 +3188,12 @@ pub async fn project_delete_internal( // rejected & withheld projects are transferred to ghost so moderation data is preserved if matches!( - project.inner.status, + original_status, ProjectStatus::Rejected | ProjectStatus::Withheld ) { let deleted_user: db_ids::DBUserId = DELETED_USER.into(); - let deleted_slug = if let Some(slug) = &project.inner.slug { + let deleted_slug = if let Some(slug) = &original_slug { let candidate = format!( "{slug}--deleted-{}", ProjectId::from(project.inner.id) @@ -3409,10 +3319,14 @@ pub async fn project_delete_internal( .await .wrap_internal_err("failed to delete project followers")?; - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; + mutation::finalize_project_edit( + project.inner.id, + original_status, + original_slug, + transaction, + &redis, + ) + .await?; let mut cache_user_ids = affected_user_ids; cache_user_ids.push(deleted_user); @@ -3422,14 +3336,6 @@ pub async fn project_delete_internal( DBTeamMember::clear_cache(project.inner.team_id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; - db_models::DBProject::clear_cache( - project.inner.id, - project.inner.slug.clone(), - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_removal(project.inner.id.into()) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs new file mode 100644 index 00000000000..1a4bd1d23f4 --- /dev/null +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -0,0 +1,210 @@ +use eyre::eyre; +use xredis::RedisPool; + +use crate::database::models::project_item::ProjectQueryResult; +use crate::database::models::{DBProjectId, DBThreadIssue}; +use crate::database::{PgTransaction, models as db_models}; +use crate::models::ids::ProjectId; +use crate::models::projects::{Project, ProjectStatus, Version}; +use crate::models::thread_issues::{ThreadIssueContext, ThreadIssueVerdict}; +use crate::routes::ApiError; +use crate::routes::internal::delphi; +use crate::util::error::{ApiContext as _, Context as _}; +use crate::validate::project::has_required_nags_with_context; + +struct SyncedProjectState { + data: ProjectQueryResult, + project: Project, + thread_issues: Vec, + has_required_nags: bool, +} + +async fn sync_project_state( + project_id: DBProjectId, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, +) -> Result { + delphi::tech_review_queue::remove_projects_without_details( + &[project_id], + delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, + transaction, + ) + .await + .wrap_api_err( + "removing project from technical review when no details remain", + )?; + + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("locking project state synchronization")?; + + let mut projects = db_models::DBProject::get_many_uncached( + &[ProjectId::from(project_id)], + &mut *transaction, + redis, + ) + .await + .wrap_internal_err("reloading project for state synchronization")?; + let data = projects.pop().wrap_not_found_err("resource not found")?; + let versions = db_models::DBVersion::get_many_uncached( + &data.versions, + &mut *transaction, + redis, + ) + .await + .wrap_internal_err("reloading project versions for state synchronization")? + .into_iter() + .map(Version::from) + .collect::>(); + let project = Project::from(data.clone()); + let available_categories = + db_models::categories::Category::list(&mut *transaction, redis) + .await + .wrap_internal_err("fetching project categories")?; + let disclosures = db_models::DBProjectDisclosure::get_many_for_project( + data.inner.id, + false, + &mut *transaction, + ) + .await + .wrap_internal_err("fetching project disclosures")? + .into_iter() + .map(|disclosure| disclosure.disclosure) + .collect::>(); + let has_required_nags = has_required_nags_with_context( + &project, + &versions, + &available_categories, + &disclosures, + ); + + let context = ThreadIssueContext { + project: &project, + versions: &versions, + }; + let thread_issues = + db_models::DBThreadIssue::sync_project_verdicts(&context, transaction) + .await + .wrap_internal_err("synchronizing project thread issue verdicts")?; + + Ok(SyncedProjectState { + data, + project, + thread_issues, + has_required_nags, + }) +} + +pub(crate) async fn finalize_mutation( + project_id: DBProjectId, + transaction: PgTransaction<'_>, + redis: &RedisPool, +) -> Result<(), ApiError> { + finalize_mutations(&[project_id], transaction, redis).await +} + +pub(crate) async fn finalize_mutations( + project_ids: &[DBProjectId], + mut transaction: PgTransaction<'_>, + redis: &RedisPool, +) -> Result<(), ApiError> { + let mut project_ids = project_ids.to_vec(); + project_ids.sort_by_key(|project_id| project_id.0); + project_ids.dedup(); + + let mut states = Vec::with_capacity(project_ids.len()); + for project_id in project_ids { + states.push( + sync_project_state(project_id, &mut transaction, redis).await?, + ); + } + + transaction + .commit() + .await + .wrap_internal_err("committing project mutation")?; + + for state in &states { + db_models::DBProject::clear_cache( + state.data.inner.id, + state.data.inner.slug.clone(), + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing cached project data")?; + } + + Ok(()) +} + +pub(crate) async fn finalize_project_edit( + project_id: DBProjectId, + original_status: ProjectStatus, + original_slug: Option, + mut transaction: PgTransaction<'_>, + redis: &RedisPool, +) -> Result<(), ApiError> { + let state = sync_project_state(project_id, &mut transaction, redis).await?; + + let entered_review = state.project.status == ProjectStatus::Processing + && original_status != ProjectStatus::Processing; + let entered_approved_state = + state.project.status.is_approved() && !original_status.is_approved(); + + if (entered_review || entered_approved_state) && state.has_required_nags { + return Err(ApiError::Request(eyre!( + "project must have no required validation nags before review or approval" + ))); + } + + if entered_review + && state + .thread_issues + .iter() + .any(|issue| issue.verdict == ThreadIssueVerdict::Open) + { + return Err(ApiError::Request(eyre!( + "all open moderation issues must be addressed before review" + ))); + } + + if entered_approved_state + && state + .thread_issues + .iter() + .any(|issue| issue.verdict != ThreadIssueVerdict::Resolved) + { + return Err(ApiError::Request(eyre!( + "all moderation issues must be resolved before approval" + ))); + } + + transaction + .commit() + .await + .wrap_internal_err("committing project mutation")?; + + db_models::DBProject::clear_cache( + project_id, + state.data.inner.slug.clone(), + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing cached project data")?; + + if original_slug != state.data.inner.slug { + db_models::DBProject::clear_cache( + project_id, + original_slug, + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing previous project slug cache")?; + } + + Ok(()) +} diff --git a/apps/labrinth/src/routes/v3/projects/validate.rs b/apps/labrinth/src/routes/v3/projects/validate.rs index 877242e7b29..5812596b158 100644 --- a/apps/labrinth/src/routes/v3/projects/validate.rs +++ b/apps/labrinth/src/routes/v3/projects/validate.rs @@ -4,21 +4,16 @@ use serde::Serialize; use xredis::RedisPool; use crate::auth::get_user_from_headers; -use crate::database::models::DBProjectId; -use crate::database::models::project_item::ProjectQueryResult; -use crate::database::{ - PgPool, PgTransaction, ReadOnlyPgPool, models as db_models, -}; -use crate::models::ids::ProjectId; +use crate::database::{PgPool, ReadOnlyPgPool, models as db_models}; use crate::models::pats::Scopes; use crate::models::projects::{Project, Version}; use crate::models::teams::ProjectPermissions; + use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context as _; use crate::validate::project::{ - ProjectNag, has_required_nags_with_context, - validate_with_context as validate_project, + ProjectNag, validate_with_context as validate_project, }; #[derive(Serialize, utoipa::ToSchema)] @@ -26,61 +21,6 @@ pub struct ProjectValidationResponse { pub nags: Vec, } -pub(crate) async fn ensure_project_is_valid_for_review( - project_id: DBProjectId, - pool: &PgPool, - transaction: &mut PgTransaction<'_>, - redis: &RedisPool, -) -> Result { - let mut projects = db_models::DBProject::get_many_uncached( - &[ProjectId::from(project_id)], - &mut *transaction, - redis, - ) - .await - .wrap_internal_err("reloading project for review validation")?; - let reloaded_project = - projects.pop().wrap_not_found_err("resource not found")?; - let versions = db_models::DBVersion::get_many_uncached( - &reloaded_project.versions, - &mut *transaction, - redis, - ) - .await - .wrap_internal_err("reloading project versions for review validation")? - .into_iter() - .map(Version::from) - .collect::>(); - let available_categories = - db_models::categories::Category::list(&**pool, redis) - .await - .wrap_internal_err("fetching project categories")?; - let disclosures = db_models::DBProjectDisclosure::get_many_for_project( - reloaded_project.inner.id, - false, - &mut *transaction, - ) - .await - .wrap_internal_err("fetching project disclosures")? - .into_iter() - .map(|disclosure| disclosure.disclosure) - .collect::>(); - let project = Project::from(reloaded_project.clone()); - - if has_required_nags_with_context( - &project, - &versions, - &available_categories, - &disclosures, - ) { - return Err(ApiError::Request(eyre!( - "project must have no required validation nags before or while under review" - ))); - } - - Ok(reloaded_project) -} - /// Validate that a project is ready to be submitted for review. #[utoipa::path( context_path = "/project", diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index ea375ff76ce..1473d1ad1f2 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -185,9 +185,12 @@ pub async fn version_create( return Err(e.into()); } } else if let Ok((_, project_id, version_id)) = &result { - transaction.commit().await?; - models::DBProject::clear_cache(*project_id, None, Some(true), &redis) - .await?; + super::projects::mutation::finalize_mutation( + *project_id, + transaction, + &redis, + ) + .await?; search_state .queue .push_version_changes( @@ -671,9 +674,12 @@ pub async fn upload_file_to_version( return Err(e.into()); } } else if let Ok((_, project_id)) = &result { - transaction.commit().await?; - models::DBProject::clear_cache(*project_id, None, Some(true), &redis) - .await?; + super::projects::mutation::finalize_mutation( + *project_id, + transaction, + &redis, + ) + .await?; search_state .queue .push_version_changes((*project_id).into(), [version_id]) diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 79eeb462eb7..7550d225510 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -8,7 +8,7 @@ use crate::models::pats::Scopes; use crate::models::projects::{ProjectStatus, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; -use crate::routes::internal::delphi; + use crate::routes::{FileHash, HashAlgorithm}; use crate::util::error::ApiContext as _; use crate::util::error::Context; @@ -970,20 +970,12 @@ pub async fn delete_file( database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction) .await.wrap_internal_err("deleting version item from database")?; - delphi::tech_review_queue::remove_projects_without_details( - &[row.project_id], - delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, - &mut transaction, + super::projects::mutation::finalize_mutation( + row.project_id, + transaction, + &redis, ) - .await - .wrap_api_err( - "executing `tech_review_sync::sync_project_tech_review_state`", - )?; - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + .await?; Ok(HttpResponse::NoContent().body("")) } else { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 9a969bc5319..52fe770f3ff 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -20,13 +20,13 @@ use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, FileType, ProjectStatus, VersionStatus, VersionType, + Dependency, FileType, VersionStatus, VersionType, }; use crate::models::projects::{Loader, skip_nulls}; use crate::models::teams::ProjectPermissions; use crate::queue::file_scan::get_files_missing_attribution; use crate::queue::session::AuthQueue; -use crate::routes::internal::delphi; + use crate::search::SearchState; use crate::util::error::Context; use crate::util::img; @@ -918,21 +918,15 @@ pub async fn version_edit_helper( .await .wrap_api_err("deleting unused images")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - database::models::DBVersion::clear_cache(&version_item, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; - database::models::DBProject::clear_cache( + super::projects::mutation::finalize_mutation( version_item.inner.project_id, - None, - Some(true), + transaction, &redis, ) - .await - .wrap_internal_err("clearing cached data from Redis")?; + .await?; + database::models::DBVersion::clear_cache(&version_item, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -1243,15 +1237,6 @@ pub async fn version_delete( .wrap_request_err_with(|| { "the specified version does not exist!".to_string() })?; - let project = database::models::DBProject::get_id( - version.inner.project_id, - &**pool, - &redis, - ) - .await - .wrap_internal_err("fetching project from database")? - .wrap_not_found_err("resource not found")?; - let validate_for_review = project.inner.status == ProjectStatus::Processing; if !user.role.is_admin() { let team_member = @@ -1326,39 +1311,12 @@ pub async fn version_delete( .await .wrap_internal_err("deleting version from database")?; - delphi::tech_review_queue::remove_projects_without_details( - &[version.inner.project_id], - delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, - &mut transaction, - ) - .await - .wrap_api_err( - "executing `tech_review_sync::sync_project_tech_review_state`", - )?; - - if validate_for_review { - super::projects::validate::ensure_project_is_valid_for_review( - version.inner.project_id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - - database::models::DBProject::clear_cache( + super::projects::mutation::finalize_mutation( version.inner.project_id, - None, - Some(true), + transaction, &redis, ) - .await - .wrap_internal_err("clearing cached data from Redis")?; + .await?; search_state .queue .push_version_changes( From 35e8f601fd070e16cf47257150b314ed711c846c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:03:34 +0100 Subject: [PATCH 05/25] add new issue target types --- apps/labrinth/src/models/v3/thread_issues.rs | 614 +++++++++++++++++- .../src/routes/v3/projects/mutation.rs | 33 +- apps/labrinth/src/routes/v3/teams.rs | 86 ++- 3 files changed, 711 insertions(+), 22 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 8acbd1228fd..c33d098658f 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -1,5 +1,10 @@ -use crate::models::ids::{GalleryImageId, ThreadIssueId}; -use crate::models::projects::{Project, Version}; +use crate::models::disclosures::ProjectDisclosure; +use crate::models::exp::minecraft::Language; +use crate::models::ids::{ + FileId, GalleryImageId, TeamId, ThreadIssueId, VersionId, +}; +use crate::models::projects::{Dependency, FileType, Project, Version}; +use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -9,6 +14,15 @@ use std::collections::HashMap; pub struct ThreadIssueContext<'a> { pub project: &'a Project, pub versions: &'a [Version], + pub disclosures: &'a [ProjectDisclosure], + pub team_members: &'a [ThreadIssueTeamMember], +} + +#[derive(Debug, Clone)] +pub struct ThreadIssueTeamMember { + pub team_id: TeamId, + pub user_id: UserId, + pub role: String, } /// Issue that a moderator has flagged on a project in its moderation thread. @@ -68,9 +82,47 @@ pub enum ThreadIssueTarget { Links { links: HashMap, }, + AddGalleryImages { + original_count: u32, + }, + ModifyGalleryImage { + image_id: GalleryImageId, + original_url: String, + name: Option, + description: Option, + }, RemoveGalleryImages { originals: Vec<(GalleryImageId, String)>, }, + RemoveProjectDisclosures { + originals: Vec<(String, serde_json::Value)>, + }, + ModifyProjectDisclosure { + disclosure_type: String, + metadata: JsonTarget, + }, + ProjectDisclosureNote { + disclosure_type: String, + note: OptionalTextTarget, + }, + Version { + version_id: VersionId, + version_number: String, + target: VersionIssueTarget, + }, + TeamMemberRole { + team_id: TeamId, + user_id: UserId, + role: TextTarget, + }, + ServerLanguages { + original: Vec, + suggestion: Option>, + }, + ServerAddress { + platform: ServerAddressPlatform, + address: TextTarget, + }, Acknowledge { mode: ThreadIssueAcknowledgement, }, @@ -86,6 +138,68 @@ pub struct TextTarget { pub suggestion: Option, } +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct OptionalTextTarget { + pub original: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub suggestion: Option>, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct JsonTarget { + pub original: serde_json::Value, + pub suggestion: Option, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum VersionIssueTarget { + Remove, + Environment(TextTarget), + GameVersions { + original: Vec, + suggestion: Option>, + }, + Dependencies { + original: Vec, + suggestion: Option>, + }, + Changelog(TextTarget), + RemoveAdditionalFiles { + originals: Vec<(FileId, String)>, + }, + AdditionalFileType { + file_id: FileId, + filename: String, + original: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + suggestion: Option>, + }, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ServerAddressPlatform { + MinecraftJava, + MinecraftBedrock, +} + /// How should a user communicate that they've resolved a [`ThreadIssue`]? #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, @@ -233,6 +347,48 @@ impl ThreadIssueTarget { } state } + Self::AddGalleryImages { original_count } => { + if project.gallery.len() > *original_count as usize { + ThreadIssueValueState::DifferentToOriginal + } else { + ThreadIssueValueState::SameAsOriginal + } + } + Self::ModifyGalleryImage { + image_id, + name, + description, + .. + } => { + let Some(image) = project + .gallery + .iter() + .find(|image| image.id.as_ref() == Some(image_id)) + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + + let name_state = name.as_ref().map(|target| { + optional_text_value_state(target, &image.name) + }); + let description_state = description.as_ref().map(|target| { + optional_text_value_state(target, &image.description) + }); + + if name_state + .into_iter() + .chain(description_state) + .all(|state| state == ThreadIssueValueState::SameAsOriginal) + { + ThreadIssueValueState::SameAsOriginal + } else if name_state.into_iter().chain(description_state).all( + |state| state == ThreadIssueValueState::SameAsSuggested, + ) { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + } + } Self::RemoveGalleryImages { originals } => { let remaining = originals .iter() @@ -252,6 +408,273 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } + Self::RemoveProjectDisclosures { originals } => { + let remaining = originals + .iter() + .filter(|(original_type, _)| { + context.disclosures.iter().any(|disclosure| { + disclosure.to_parts().is_ok_and( + |(current_type, _)| { + current_type == original_type + }, + ) + }) + }) + .count(); + + if remaining == 0 { + ThreadIssueValueState::SameAsSuggested + } else if remaining == originals.len() { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::ModifyProjectDisclosure { + disclosure_type, + metadata, + } => { + let current = + context.disclosures.iter().find_map(|disclosure| { + let (current_type, mut current) = + disclosure.to_parts().ok()?; + if current_type != disclosure_type { + return None; + } + if let Some(current) = current.as_object_mut() { + current.remove("note"); + } + Some(current) + }); + + let mut original = metadata.original.clone(); + if let Some(original) = original.as_object_mut() { + original.remove("note"); + } + let suggestion = + metadata.suggestion.as_ref().map(|suggestion| { + let mut suggestion = suggestion.clone(); + if let Some(suggestion) = suggestion.as_object_mut() { + suggestion.remove("note"); + } + suggestion + }); + + match current { + Some(current) if suggestion.as_ref() == Some(¤t) => { + ThreadIssueValueState::SameAsSuggested + } + Some(current) if current == original => { + ThreadIssueValueState::SameAsOriginal + } + _ => ThreadIssueValueState::DifferentToOriginal, + } + } + Self::ProjectDisclosureNote { + disclosure_type, + note, + } => { + let current = + context.disclosures.iter().find_map(|disclosure| { + let (current_type, metadata) = + disclosure.to_parts().ok()?; + if current_type != disclosure_type { + return None; + } + Some( + metadata + .get("note") + .and_then(serde_json::Value::as_str) + .map(String::from), + ) + }); + + current.map_or( + ThreadIssueValueState::DifferentToOriginal, + |current| optional_text_value_state(note, ¤t), + ) + } + Self::Version { + version_id, target, .. + } => { + let version = context + .versions + .iter() + .find(|version| version.id == *version_id); + + if matches!(target, VersionIssueTarget::Remove) { + if version.is_none() { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::SameAsOriginal + } + } else { + let Some(version) = version else { + return ThreadIssueValueState::DifferentToOriginal; + }; + + match target { + VersionIssueTarget::Remove => unreachable!(), + VersionIssueTarget::Environment(target) => value_state( + target, + version + .fields + .get("environment") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ), + VersionIssueTarget::GameVersions { + original, + suggestion, + } => { + let current = version + .fields + .get("game_versions") + .and_then(serde_json::Value::as_array) + .map(|versions| { + versions + .iter() + .filter_map(|version| { + version.as_str().map(String::from) + }) + .collect::>() + }) + .unwrap_or_default(); + if suggestion.as_ref().is_some_and(|suggestion| { + suggestion.len() == current.len() + && suggestion.iter().all(|version| { + current.contains(version) + }) + }) { + ThreadIssueValueState::SameAsSuggested + } else if original.len() == current.len() + && original + .iter() + .all(|version| current.contains(version)) + { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + VersionIssueTarget::Dependencies { + original, + suggestion, + } => { + if suggestion.as_ref().is_some_and(|suggestion| { + suggestion.len() == version.dependencies.len() + && suggestion.iter().all(|dependency| { + version + .dependencies + .contains(dependency) + }) + }) { + ThreadIssueValueState::SameAsSuggested + } else if original.len() + == version.dependencies.len() + && original.iter().all(|dependency| { + version.dependencies.contains(dependency) + }) + { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + VersionIssueTarget::Changelog(target) => value_state( + target, + version.changelog.as_deref().unwrap_or_default(), + ), + VersionIssueTarget::RemoveAdditionalFiles { + originals, + } => { + let remaining = originals + .iter() + .filter(|(id, _)| { + version.files.iter().any(|file| { + file.id.as_ref() == Some(id) + }) + }) + .count(); + if remaining == 0 { + ThreadIssueValueState::SameAsSuggested + } else if remaining == originals.len() { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + VersionIssueTarget::AdditionalFileType { + file_id, + original, + suggestion, + .. + } => { + let Some(file) = version + .files + .iter() + .find(|file| file.id.as_ref() == Some(file_id)) + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + if suggestion.as_ref() == Some(&file.file_type) { + ThreadIssueValueState::SameAsSuggested + } else if file.file_type == *original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + } + } + } + Self::TeamMemberRole { + team_id, + user_id, + role, + } => context + .team_members + .iter() + .find(|member| { + member.team_id == *team_id && member.user_id == *user_id + }) + .map_or(ThreadIssueValueState::DifferentToOriginal, |member| { + value_state(role, &member.role) + }), + Self::ServerLanguages { + original, + suggestion, + } => { + let Some(server) = project.components.minecraft_server.as_ref() + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + if suggestion.as_ref() == Some(&server.languages) { + ThreadIssueValueState::SameAsSuggested + } else if server.languages == *original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::ServerAddress { platform, address } => { + let current = match platform { + ServerAddressPlatform::MinecraftJava => project + .components + .minecraft_java_server + .as_ref() + .map(|server| server.address.as_str()), + ServerAddressPlatform::MinecraftBedrock => project + .components + .minecraft_bedrock_server + .as_ref() + .map(|server| server.address.as_str()), + }; + current.map_or( + ThreadIssueValueState::DifferentToOriginal, + |current| value_state(address, current), + ) + } Self::Acknowledge { mode } => match (mode, user_addressed) { (_, false) => ThreadIssueValueState::SameAsOriginal, (ThreadIssueAcknowledgement::Checkbox, true) => { @@ -265,6 +688,19 @@ impl ThreadIssueTarget { } } +fn optional_text_value_state( + target: &OptionalTextTarget, + current: &Option, +) -> ThreadIssueValueState { + if target.suggestion.as_ref() == Some(current) { + ThreadIssueValueState::SameAsSuggested + } else if *current == target.original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } +} + fn value_state(target: &TextTarget, current: &str) -> ThreadIssueValueState { if target.suggestion.as_deref() == Some(current) { ThreadIssueValueState::SameAsSuggested @@ -389,6 +825,8 @@ mod tests { ThreadIssueContext { project, versions: &[], + disclosures: &[], + team_members: &[], } } @@ -399,6 +837,35 @@ mod tests { } } + fn optional_text_target( + original: Option<&str>, + suggestion: Option>, + ) -> OptionalTextTarget { + OptionalTextTarget { + original: original.map(str::to_string), + suggestion: suggestion + .map(|suggestion| suggestion.map(str::to_string)), + } + } + + #[test] + fn nullable_suggestions_round_trip() { + let target = OptionalTextTarget { + original: Some("Original".to_string()), + suggestion: Some(None), + }; + let serialized = serde_json::to_value(&target).unwrap(); + + assert_eq!( + serialized.get("suggestion"), + Some(&serde_json::Value::Null) + ); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + target + ); + } + #[test] fn text_target_value_states() { let mut project = project(); @@ -534,6 +1001,53 @@ mod tests { ); } + #[test] + fn gallery_addition_and_metadata_value_states() { + let mut project = project(); + let add = ThreadIssueTarget::AddGalleryImages { + original_count: project.gallery.len() as u32, + }; + let metadata = ThreadIssueTarget::ModifyGalleryImage { + image_id: GalleryImageId(1), + original_url: "https://example.com/gallery/1".to_string(), + name: Some(optional_text_target(None, Some(Some("Screenshot")))), + description: Some(optional_text_target( + None, + Some(Some("Description")), + )), + }; + + assert_eq!( + add.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + let mut added = project.gallery[0].clone(); + added.id = Some(GalleryImageId(3)); + project.gallery.push(added); + project.gallery[0].name = Some("Screenshot".to_string()); + project.gallery[0].description = Some("Description".to_string()); + + assert_eq!( + add.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + + project.gallery[0].description = Some("Unrelated text".to_string()); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + } + #[test] fn all_gallery_targets_must_be_removed() { let mut project = project(); @@ -568,6 +1082,102 @@ mod tests { ); } + #[test] + fn disclosure_value_states() { + let project = project(); + let mut disclosures = vec![ProjectDisclosure::Advertisements { + note: Some("Original note".to_string()), + }]; + let remove = ThreadIssueTarget::RemoveProjectDisclosures { + originals: vec![( + "advertisements".to_string(), + serde_json::json!({ "note": "Original note" }), + )], + }; + let note = ThreadIssueTarget::ProjectDisclosureNote { + disclosure_type: "advertisements".to_string(), + note: optional_text_target( + Some("Original note"), + Some(Some("Suggested note")), + ), + }; + let value_state = |target: &ThreadIssueTarget, disclosures| { + target.value_state( + &ThreadIssueContext { + project: &project, + versions: &[], + disclosures, + team_members: &[], + }, + false, + ) + }; + + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + value_state(¬e, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + + disclosures[0] = ProjectDisclosure::Advertisements { + note: Some("Suggested note".to_string()), + }; + assert_eq!( + value_state(¬e, &disclosures), + ThreadIssueValueState::SameAsSuggested + ); + + disclosures.clear(); + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsSuggested + ); + } + + #[test] + fn team_member_role_value_states() { + let project = project(); + let mut members = vec![ThreadIssueTeamMember { + team_id: TeamId(2), + user_id: UserId(4), + role: "Developer".to_string(), + }]; + let target = ThreadIssueTarget::TeamMemberRole { + team_id: TeamId(2), + user_id: UserId(4), + role: text_target("Developer", Some("Artist")), + }; + let value_state = |members: &[ThreadIssueTeamMember]| { + target.value_state( + &ThreadIssueContext { + project: &project, + versions: &[], + disclosures: &[], + team_members: members, + }, + false, + ) + }; + + assert_eq!( + value_state(&members), + ThreadIssueValueState::SameAsOriginal + ); + members[0].role = "Artist".to_string(); + assert_eq!( + value_state(&members), + ThreadIssueValueState::SameAsSuggested + ); + members.clear(); + assert_eq!( + value_state(&members), + ThreadIssueValueState::DifferentToOriginal + ); + } + #[test] fn acknowledgement_value_states() { let checkbox = ThreadIssueTarget::Acknowledge { diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 1a4bd1d23f4..689d5d2a3c2 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -2,11 +2,13 @@ use eyre::eyre; use xredis::RedisPool; use crate::database::models::project_item::ProjectQueryResult; -use crate::database::models::{DBProjectId, DBThreadIssue}; +use crate::database::models::{DBProjectId, DBTeamId, DBThreadIssue, DBUserId}; use crate::database::{PgTransaction, models as db_models}; use crate::models::ids::ProjectId; use crate::models::projects::{Project, ProjectStatus, Version}; -use crate::models::thread_issues::{ThreadIssueContext, ThreadIssueVerdict}; +use crate::models::thread_issues::{ + ThreadIssueContext, ThreadIssueTeamMember, ThreadIssueVerdict, +}; use crate::routes::ApiError; use crate::routes::internal::delphi; use crate::util::error::{ApiContext as _, Context as _}; @@ -72,6 +74,31 @@ async fn sync_project_state( .into_iter() .map(|disclosure| disclosure.disclosure) .collect::>(); + let team_members = sqlx::query!( + r#" + SELECT team_id, user_id, role + FROM team_members + WHERE + team_id = $1 + OR team_id = ( + SELECT team_id + FROM organizations + WHERE id = $2 + ) + "#, + data.inner.team_id as DBTeamId, + data.inner.organization_id.map(|id| id.0), + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching project team members")? + .into_iter() + .map(|member| ThreadIssueTeamMember { + team_id: DBTeamId(member.team_id).into(), + user_id: DBUserId(member.user_id).into(), + role: member.role, + }) + .collect::>(); let has_required_nags = has_required_nags_with_context( &project, &versions, @@ -82,6 +109,8 @@ async fn sync_project_state( let context = ThreadIssueContext { project: &project, versions: &versions, + disclosures: &disclosures, + team_members: &team_members, }; let thread_issues = db_models::DBThreadIssue::sync_project_verdicts(&context, transaction) diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 5cb8e173bb6..377efd51637 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -1,10 +1,12 @@ use crate::auth::checks::{is_visible_organization, is_visible_project}; use crate::auth::get_user_from_headers; use crate::database::DBProject; -use crate::database::PgPool; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::team_item::TeamAssociationId; -use crate::database::models::{DBOrganization, DBTeam, DBTeamMember, DBUser}; +use crate::database::models::{ + DBOrganization, DBProjectId, DBTeam, DBTeamMember, DBUser, +}; +use crate::database::{PgPool, PgTransaction}; use crate::models::ids::TeamId; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; @@ -30,6 +32,31 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(transfer_ownership_route); } +async fn affected_project_ids( + association: TeamAssociationId, + transaction: &mut PgTransaction<'_>, +) -> Result, ApiError> { + match association { + TeamAssociationId::Project(project_id) => Ok(vec![project_id]), + TeamAssociationId::Organization(organization_id) => { + sqlx::query_scalar!( + r#" + SELECT id + FROM mods + WHERE organization_id = $1 + "#, + organization_id as _, + ) + .fetch_all(&mut *transaction) + .await + .map(|project_ids| { + project_ids.into_iter().map(DBProjectId).collect() + }) + .wrap_internal_err("fetching projects affected by team mutation") + } + } +} + // Returns all members of a project, // including the team members of the project's team, but // also the members of the organization's team if the project is associated with an organization @@ -763,10 +790,14 @@ pub async fn add_team_member( } } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + ) + .await?; DBTeamMember::clear_cache(team_id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; @@ -990,10 +1021,14 @@ pub async fn edit_team_member( .await .wrap_internal_err("updating team member in database")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + ) + .await?; DBTeamMember::clear_cache(id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; @@ -1204,10 +1239,21 @@ pub async fn transfer_ownership( )?; } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + if let Some(team_association_id) = team_association_id { + let affected_project_ids = + affected_project_ids(team_association_id, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + ) + .await?; + } else { + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + } DBTeamMember::clear_cache(id.into(), &redis) .await @@ -1393,10 +1439,14 @@ pub async fn remove_team_member( } } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + ) + .await?; DBTeamMember::clear_cache(id, &redis) .await From 8a47bde3ed6ac0f8d63c333006034ba6d32aa5c5 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:36:33 +0100 Subject: [PATCH 06/25] adjust issue types --- apps/labrinth/src/models/v3/thread_issues.rs | 195 ++++++++++--------- 1 file changed, 98 insertions(+), 97 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index c33d098658f..9b8330ac243 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -65,21 +65,21 @@ pub struct ThreadIssue { )] #[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum ThreadIssueTarget { - Title(TextTarget), - Slug(TextTarget), - Summary(TextTarget), - Description(TextTarget), - License { + ModifyTitle(TextTarget), + ModifySlug(TextTarget), + ModifySummary(TextTarget), + ModifyDescription(TextTarget), + ModifyLicense { license: TextTarget, url: TextTarget, }, - Icon { + ModifyIcon { original_url: Option, }, - Tags { - original: Vec, + RemoveTags { + tags: Vec, }, - Links { + ModifyLinks { links: HashMap, }, AddGalleryImages { @@ -92,16 +92,16 @@ pub enum ThreadIssueTarget { description: Option, }, RemoveGalleryImages { - originals: Vec<(GalleryImageId, String)>, + image_ids: Vec, }, RemoveProjectDisclosures { - originals: Vec<(String, serde_json::Value)>, + disclosure_types: Vec, }, ModifyProjectDisclosure { disclosure_type: String, metadata: JsonTarget, }, - ProjectDisclosureNote { + ModifyProjectDisclosureNote { disclosure_type: String, note: OptionalTextTarget, }, @@ -110,16 +110,16 @@ pub enum ThreadIssueTarget { version_number: String, target: VersionIssueTarget, }, - TeamMemberRole { + ModifyTeamMemberRole { team_id: TeamId, user_id: UserId, role: TextTarget, }, - ServerLanguages { + ModifyServerLanguages { original: Vec, suggestion: Option>, }, - ServerAddress { + ModifyServerAddress { platform: ServerAddressPlatform, address: TextTarget, }, @@ -165,20 +165,20 @@ pub struct JsonTarget { #[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum VersionIssueTarget { Remove, - Environment(TextTarget), - GameVersions { + ModifyEnvironment(TextTarget), + ModifyGameVersions { original: Vec, suggestion: Option>, }, - Dependencies { + ModifyDependencies { original: Vec, suggestion: Option>, }, - Changelog(TextTarget), + ModifyChangelog(TextTarget), RemoveAdditionalFiles { - originals: Vec<(FileId, String)>, + file_ids: Vec, }, - AdditionalFileType { + ModifyAdditionalFileType { file_id: FileId, filename: String, original: Option, @@ -278,15 +278,17 @@ impl ThreadIssueTarget { ) -> ThreadIssueValueState { let project = context.project; match self { - Self::Title(target) => value_state(target, &project.name), - Self::Slug(target) => { + Self::ModifyTitle(target) => value_state(target, &project.name), + Self::ModifySlug(target) => { value_state(target, project.slug.as_deref().unwrap_or_default()) } - Self::Summary(target) => value_state(target, &project.summary), - Self::Description(target) => { + Self::ModifySummary(target) => { + value_state(target, &project.summary) + } + Self::ModifyDescription(target) => { value_state(target, &project.description) } - Self::License { license, url } => { + Self::ModifyLicense { license, url } => { let license_state = value_state(license, project.license.id.as_str()); let url_state = value_state( @@ -306,27 +308,30 @@ impl ThreadIssueTarget { _ => ThreadIssueValueState::SameAsOriginal, } } - Self::Icon { original_url } => { + Self::ModifyIcon { original_url } => { if project.icon_url == *original_url { ThreadIssueValueState::SameAsOriginal } else { ThreadIssueValueState::DifferentToOriginal } } - Self::Tags { original } => { - let current = project - .categories + Self::RemoveTags { tags } => { + let remaining = tags .iter() - .chain(&project.additional_categories) - .cloned() - .collect::>(); - if current == *original { + .filter(|tag| { + project.categories.contains(tag) + || project.additional_categories.contains(tag) + }) + .count(); + if remaining == 0 { + ThreadIssueValueState::SameAsSuggested + } else if remaining == tags.len() { ThreadIssueValueState::SameAsOriginal } else { ThreadIssueValueState::DifferentToOriginal } } - Self::Links { links } => { + Self::ModifyLinks { links } => { let mut state = ThreadIssueValueState::SameAsOriginal; for (platform, target) in links { let current = project @@ -389,10 +394,10 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - Self::RemoveGalleryImages { originals } => { - let remaining = originals + Self::RemoveGalleryImages { image_ids } => { + let remaining = image_ids .iter() - .filter(|(id, _)| { + .filter(|id| { project .gallery .iter() @@ -402,21 +407,19 @@ impl ThreadIssueTarget { if remaining == 0 { ThreadIssueValueState::SameAsSuggested - } else if remaining == originals.len() { + } else if remaining == image_ids.len() { ThreadIssueValueState::SameAsOriginal } else { ThreadIssueValueState::DifferentToOriginal } } - Self::RemoveProjectDisclosures { originals } => { - let remaining = originals + Self::RemoveProjectDisclosures { disclosure_types } => { + let remaining = disclosure_types .iter() - .filter(|(original_type, _)| { + .filter(|target| { context.disclosures.iter().any(|disclosure| { disclosure.to_parts().is_ok_and( - |(current_type, _)| { - current_type == original_type - }, + |(current_type, _)| current_type == *target, ) }) }) @@ -424,7 +427,7 @@ impl ThreadIssueTarget { if remaining == 0 { ThreadIssueValueState::SameAsSuggested - } else if remaining == originals.len() { + } else if remaining == disclosure_types.len() { ThreadIssueValueState::SameAsOriginal } else { ThreadIssueValueState::DifferentToOriginal @@ -470,7 +473,7 @@ impl ThreadIssueTarget { _ => ThreadIssueValueState::DifferentToOriginal, } } - Self::ProjectDisclosureNote { + Self::ModifyProjectDisclosureNote { disclosure_type, note, } => { @@ -515,15 +518,17 @@ impl ThreadIssueTarget { match target { VersionIssueTarget::Remove => unreachable!(), - VersionIssueTarget::Environment(target) => value_state( - target, - version - .fields - .get("environment") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(), - ), - VersionIssueTarget::GameVersions { + VersionIssueTarget::ModifyEnvironment(target) => { + value_state( + target, + version + .fields + .get("environment") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ) + } + VersionIssueTarget::ModifyGameVersions { original, suggestion, } => { @@ -557,7 +562,7 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - VersionIssueTarget::Dependencies { + VersionIssueTarget::ModifyDependencies { original, suggestion, } => { @@ -581,16 +586,21 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - VersionIssueTarget::Changelog(target) => value_state( - target, - version.changelog.as_deref().unwrap_or_default(), - ), + VersionIssueTarget::ModifyChangelog(target) => { + value_state( + target, + version + .changelog + .as_deref() + .unwrap_or_default(), + ) + } VersionIssueTarget::RemoveAdditionalFiles { - originals, + file_ids, } => { - let remaining = originals + let remaining = file_ids .iter() - .filter(|(id, _)| { + .filter(|id| { version.files.iter().any(|file| { file.id.as_ref() == Some(id) }) @@ -598,13 +608,13 @@ impl ThreadIssueTarget { .count(); if remaining == 0 { ThreadIssueValueState::SameAsSuggested - } else if remaining == originals.len() { + } else if remaining == file_ids.len() { ThreadIssueValueState::SameAsOriginal } else { ThreadIssueValueState::DifferentToOriginal } } - VersionIssueTarget::AdditionalFileType { + VersionIssueTarget::ModifyAdditionalFileType { file_id, original, suggestion, @@ -628,7 +638,7 @@ impl ThreadIssueTarget { } } } - Self::TeamMemberRole { + Self::ModifyTeamMemberRole { team_id, user_id, role, @@ -641,7 +651,7 @@ impl ThreadIssueTarget { .map_or(ThreadIssueValueState::DifferentToOriginal, |member| { value_state(role, &member.role) }), - Self::ServerLanguages { + Self::ModifyServerLanguages { original, suggestion, } => { @@ -657,7 +667,7 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - Self::ServerAddress { platform, address } => { + Self::ModifyServerAddress { platform, address } => { let current = match platform { ServerAddressPlatform::MinecraftJava => project .components @@ -869,7 +879,7 @@ mod tests { #[test] fn text_target_value_states() { let mut project = project(); - let target = ThreadIssueTarget::Title(text_target( + let target = ThreadIssueTarget::ModifyTitle(text_target( "Example Project", Some("Suggested Project"), )); @@ -896,7 +906,7 @@ mod tests { fn absent_slug_uses_an_empty_current_value() { let mut project = project(); project.slug = None; - let target = ThreadIssueTarget::Slug(text_target("", None)); + let target = ThreadIssueTarget::ModifySlug(text_target("", None)); assert_eq!( target.value_state(&context(&project), false), @@ -907,7 +917,7 @@ mod tests { #[test] fn license_value_states() { let mut project = project(); - let target = ThreadIssueTarget::License { + let target = ThreadIssueTarget::ModifyLicense { license: text_target("MIT", Some("Apache-2.0")), url: text_target("https://example.com/license", None), }; @@ -934,7 +944,7 @@ mod tests { #[test] fn link_value_states() { let mut project = project(); - let target = ThreadIssueTarget::Links { + let target = ThreadIssueTarget::ModifyLinks { links: HashMap::from([ ( "source".to_string(), @@ -973,11 +983,11 @@ mod tests { #[test] fn icon_and_tag_value_states() { let mut project = project(); - let icon = ThreadIssueTarget::Icon { + let icon = ThreadIssueTarget::ModifyIcon { original_url: project.icon_url.clone(), }; - let tags = ThreadIssueTarget::Tags { - original: vec!["technology".to_string(), "utility".to_string()], + let tags = ThreadIssueTarget::RemoveTags { + tags: vec!["technology".to_string(), "utility".to_string()], }; assert_eq!( @@ -999,6 +1009,12 @@ mod tests { tags.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); + + project.categories.clear(); + assert_eq!( + tags.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); } #[test] @@ -1052,16 +1068,7 @@ mod tests { fn all_gallery_targets_must_be_removed() { let mut project = project(); let target = ThreadIssueTarget::RemoveGalleryImages { - originals: vec![ - ( - GalleryImageId(1), - "https://example.com/gallery/1".to_string(), - ), - ( - GalleryImageId(2), - "https://example.com/gallery/2".to_string(), - ), - ], + image_ids: vec![GalleryImageId(1), GalleryImageId(2)], }; assert_eq!( @@ -1089,12 +1096,9 @@ mod tests { note: Some("Original note".to_string()), }]; let remove = ThreadIssueTarget::RemoveProjectDisclosures { - originals: vec![( - "advertisements".to_string(), - serde_json::json!({ "note": "Original note" }), - )], + disclosure_types: vec!["advertisements".to_string()], }; - let note = ThreadIssueTarget::ProjectDisclosureNote { + let note = ThreadIssueTarget::ModifyProjectDisclosureNote { disclosure_type: "advertisements".to_string(), note: optional_text_target( Some("Original note"), @@ -1145,7 +1149,7 @@ mod tests { user_id: UserId(4), role: "Developer".to_string(), }]; - let target = ThreadIssueTarget::TeamMemberRole { + let target = ThreadIssueTarget::ModifyTeamMemberRole { team_id: TeamId(2), user_id: UserId(4), role: text_target("Developer", Some("Artist")), @@ -1204,7 +1208,7 @@ mod tests { #[test] fn verdict_tracks_resolution_actions() { let mut project = project(); - let target = ThreadIssueTarget::Title(text_target( + let target = ThreadIssueTarget::ModifyTitle(text_target( "Example Project", Some("Suggested Project"), )); @@ -1241,10 +1245,7 @@ mod tests { fn gallery_and_acknowledgement_verdicts() { let mut project = project(); let gallery = ThreadIssueTarget::RemoveGalleryImages { - originals: vec![( - GalleryImageId(1), - "https://example.com/gallery/1".to_string(), - )], + image_ids: vec![GalleryImageId(1)], }; let checkbox = ThreadIssueTarget::Acknowledge { mode: ThreadIssueAcknowledgement::Checkbox, From ca5ee75767b319c8d51be5d352d61a0b0cb8d0e4 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:08:57 +0100 Subject: [PATCH 07/25] CRUD routes for thread issues --- .../20260916120000_thread_issues.sql | 1 + .../src/database/models/thread_issue_item.rs | 176 ++++++++- .../src/database/models/thread_item.rs | 20 +- apps/labrinth/src/models/v3/thread_issues.rs | 11 +- apps/labrinth/src/models/v3/threads.rs | 3 + .../routes/internal/moderation/tech_review.rs | 2 + apps/labrinth/src/routes/v3/mod.rs | 7 +- apps/labrinth/src/routes/v3/threads.rs | 358 +++++++++++++++++- 8 files changed, 546 insertions(+), 32 deletions(-) diff --git a/apps/labrinth/migrations/20260916120000_thread_issues.sql b/apps/labrinth/migrations/20260916120000_thread_issues.sql index 26e5efa4d07..f5814a7432b 100644 --- a/apps/labrinth/migrations/20260916120000_thread_issues.sql +++ b/apps/labrinth/migrations/20260916120000_thread_issues.sql @@ -1,6 +1,7 @@ CREATE TABLE threads_issues ( id BIGINT PRIMARY KEY, thread_id BIGINT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + created_by BIGINT NOT NULL REFERENCES users(id) ON UPDATE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, what JSONB NOT NULL, why JSONB NOT NULL, diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs index 8c647961925..953b304a353 100644 --- a/apps/labrinth/src/database/models/thread_issue_item.rs +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -1,4 +1,6 @@ -use super::ids::{DBThreadId, DBThreadIssueId}; +use super::ids::{ + DBThreadId, DBThreadIssueId, DBUserId, generate_thread_issue_id, +}; use crate::database::PgTransaction; use crate::models::thread_issues::{ ThreadIssueContext, ThreadIssueTarget, ThreadIssueVerdict, @@ -8,10 +10,16 @@ use eyre::{Result, WrapErr, eyre}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; +pub struct ThreadIssueBuilder { + pub what: ThreadIssueTarget, + pub why: serde_json::Value, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DBThreadIssue { pub id: DBThreadIssueId, pub thread_id: DBThreadId, + pub created_by: DBUserId, pub what: ThreadIssueTarget, pub why: serde_json::Value, pub user_addressed: bool, @@ -21,6 +29,147 @@ pub struct DBThreadIssue { } impl DBThreadIssue { + pub async fn get_many_for_threads<'a, E>( + thread_ids: &[DBThreadId], + exec: E, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + + let thread_ids = thread_ids.iter().map(|id| id.0).collect::>(); + let rows = sqlx::query!( + r#" + SELECT + id, + thread_id, + created_by, + what AS "what: Json", + why, + user_addressed, + moderator_verified, + verdict, + created_at + FROM threads_issues + WHERE thread_id = ANY($1) + ORDER BY created_at, id + "#, + &thread_ids, + ) + .fetch_all(exec) + .await + .wrap_err("fetching thread issues")?; + + rows.into_iter() + .map(|row| { + Ok(Self { + id: DBThreadIssueId(row.id), + thread_id: DBThreadId(row.thread_id), + created_by: DBUserId(row.created_by), + what: row.what.0, + why: row.why, + user_addressed: row.user_addressed, + moderator_verified: row.moderator_verified, + verdict: parse_verdict(row.id, &row.verdict)?, + created_at: row.created_at, + }) + }) + .collect() + } + + pub async fn insert_many( + thread_id: DBThreadId, + created_by: DBUserId, + issues: Vec, + transaction: &mut PgTransaction<'_>, + ) -> Result> { + let mut ids = Vec::with_capacity(issues.len()); + for issue in issues { + let id = generate_thread_issue_id(&mut *transaction) + .await + .wrap_err("generating thread issue ID")?; + let what = serde_json::to_value(issue.what) + .wrap_err("serializing thread issue target")?; + + sqlx::query!( + r#" + INSERT INTO threads_issues ( + id, + thread_id, + created_by, + what, + why, + user_addressed, + moderator_verified, + verdict + ) + VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, $6) + "#, + id as DBThreadIssueId, + thread_id as DBThreadId, + created_by as DBUserId, + what, + issue.why, + ThreadIssueVerdict::Open.as_str(), + ) + .execute(&mut *transaction) + .await + .wrap_err("inserting thread issue")?; + + ids.push(id); + } + + Ok(ids) + } + + pub async fn update_flags( + id: DBThreadIssueId, + user_addressed: Option, + moderator_verified: Option, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + r#" + UPDATE threads_issues + SET + user_addressed = COALESCE($2, user_addressed), + moderator_verified = COALESCE($3, moderator_verified), + verdict = CASE + WHEN $3 IS TRUE THEN $4 + ELSE verdict + END + WHERE id = $1 + "#, + id as DBThreadIssueId, + user_addressed, + moderator_verified, + ThreadIssueVerdict::Resolved.as_str(), + ) + .execute(&mut *transaction) + .await + .wrap_err("updating thread issue")?; + + Ok(result.rows_affected() > 0) + } + + pub async fn remove( + id: DBThreadIssueId, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM threads_issues WHERE id = $1", + id as DBThreadIssueId, + ) + .execute(&mut *transaction) + .await + .wrap_err("deleting thread issue")?; + + Ok(result.rows_affected() > 0) + } + pub async fn sync_project_verdicts( context: &ThreadIssueContext<'_>, transaction: &mut PgTransaction<'_>, @@ -30,6 +179,7 @@ impl DBThreadIssue { SELECT id, thread_id, + created_by, what AS "what: Json", why, user_addressed, @@ -51,17 +201,7 @@ impl DBThreadIssue { let mut issues = Vec::with_capacity(rows.len()); for row in rows { - let stored_verdict = match row.verdict.as_str() { - "open" => ThreadIssueVerdict::Open, - "addressed" => ThreadIssueVerdict::Addressed, - "resolved" => ThreadIssueVerdict::Resolved, - verdict => { - return Err(eyre!( - "thread issue `{}` has invalid verdict `{verdict}`", - row.id - )); - } - }; + let stored_verdict = parse_verdict(row.id, &row.verdict)?; let what = row.what.0; let verdict = what.verdict( context, @@ -87,6 +227,7 @@ impl DBThreadIssue { issues.push(Self { id: DBThreadIssueId(row.id), thread_id: DBThreadId(row.thread_id), + created_by: DBUserId(row.created_by), what, why: row.why, user_addressed: row.user_addressed, @@ -99,3 +240,14 @@ impl DBThreadIssue { Ok(issues) } } + +fn parse_verdict(id: i64, verdict: &str) -> Result { + match verdict { + "open" => Ok(ThreadIssueVerdict::Open), + "addressed" => Ok(ThreadIssueVerdict::Addressed), + "resolved" => Ok(ThreadIssueVerdict::Resolved), + verdict => { + Err(eyre!("thread issue `{id}` has invalid verdict `{verdict}`")) + } + } +} diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs index 273cc3ae0cb..ac44d97003e 100644 --- a/apps/labrinth/src/database/models/thread_item.rs +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -21,6 +21,7 @@ pub struct DBThread { pub type_: ThreadType, pub messages: Vec, + pub issues: Vec, pub members: Vec, } @@ -144,7 +145,7 @@ impl DBThread { let thread_ids_parsed: Vec = thread_ids.iter().map(|x| x.0).collect(); - let threads = sqlx::query!( + let mut threads = sqlx::query!( " SELECT t.id, t.thread_type, t.mod_id, t.report_id, ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members, @@ -172,12 +173,29 @@ impl DBThread { messages.sort_by_key(|a| a.created); messages }, + issues: Vec::new(), members: x.members.unwrap_or_default().into_iter().map(DBUserId).collect(), }) .try_collect::>() .await .wrap_err("fetching threads")?; + let mut issues = + super::DBThreadIssue::get_many_for_threads(thread_ids, exec) + .await + .wrap_err("fetching thread issues")? + .into_iter() + .fold( + std::collections::HashMap::>::new(), + |mut issues, issue| { + issues.entry(issue.thread_id).or_default().push(issue); + issues + }, + ); + for thread in &mut threads { + thread.issues = issues.remove(&thread.id).unwrap_or_default(); + } + Ok(threads) } diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 9b8330ac243..fe3417d66a4 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -35,6 +35,7 @@ pub struct ThreadIssueTeamMember { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ThreadIssue { pub id: ThreadIssueId, + pub created_by: UserId, pub created_at: DateTime, /// What part of a project this issue applies to. pub what: ThreadIssueTarget, @@ -52,9 +53,6 @@ pub struct ThreadIssue { /// If a moderator marks this issue as verified, then it will locked to /// [`ThreadIssueVerdict::Resolved`]. pub moderator_verified: bool, - /// Has the user changed the affected project part since this issue was - /// added? - pub value_state: ThreadIssueValueState, /// Final derived verdict of this issue. pub verdict: ThreadIssueVerdict, } @@ -722,17 +720,14 @@ fn value_state(target: &TextTarget, current: &str) -> ThreadIssueValueState { } impl ThreadIssue { - pub fn from( - data: crate::database::models::DBThreadIssue, - value_state: ThreadIssueValueState, - ) -> Self { + pub fn from(data: crate::database::models::DBThreadIssue) -> Self { Self { id: data.id.into(), + created_by: data.created_by.into(), what: data.what, why: data.why, user_addressed: data.user_addressed, moderator_verified: data.moderator_verified, - value_state, verdict: data.verdict, created_at: data.created_at, } diff --git a/apps/labrinth/src/models/v3/threads.rs b/apps/labrinth/src/models/v3/threads.rs index cb38ba11fa2..e8646269d9f 100644 --- a/apps/labrinth/src/models/v3/threads.rs +++ b/apps/labrinth/src/models/v3/threads.rs @@ -3,6 +3,7 @@ use crate::models::ids::{ ImageId, ProjectId, ReportId, ThreadId, ThreadMessageId, }; use crate::models::projects::ProjectStatus; +use crate::models::thread_issues::ThreadIssue; use crate::models::users::User; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; @@ -16,6 +17,7 @@ pub struct Thread { pub project_id: Option, pub report_id: Option, pub messages: Vec, + pub issues: Vec, pub members: Vec, } @@ -130,6 +132,7 @@ impl Thread { .filter(|x| user.role.is_mod() || !x.body.is_private()) .map(|x| ThreadMessage::from(x, user)) .collect(), + issues: data.issues.into_iter().map(ThreadIssue::from).collect(), members: users, } } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 9e5b071aa7f..9005e11774b 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -921,6 +921,7 @@ pub async fn search_projects( .messages .iter() .filter_map(|message| message.author_id) + .chain(thread.issues.iter().map(|issue| issue.created_by)) }) .collect::>(); let thread_authors = @@ -1030,6 +1031,7 @@ pub async fn get_project_report( .messages .iter() .filter_map(|message| message.author_id) + .chain(thread.issues.iter().map(|issue| issue.created_by)) }) .collect::>(); let thread_authors = diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 1d5235fc99f..bad08d3da1c 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -194,8 +194,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { teams::remove_team_member_route, threads::thread_get_route, threads::threads_get_route, - threads::thread_send_message_route, - threads::message_delete_route, + threads::thread_send_message_route, + threads::thread_issues_create, + threads::thread_issue_edit, + threads::thread_issue_delete, + threads::message_delete_route, users::all_projects, users::admin_user_email, users::admin_user_discord, diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 4d7d82167d1..98553f5959a 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -1,23 +1,27 @@ -use crate::auth::get_user_from_headers; +use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::database; use crate::database::PgPool; use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::thread_issue_item::ThreadIssueBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models::ids::{ThreadId, ThreadMessageId}; +use crate::models::ids::{ThreadId, ThreadIssueId, ThreadMessageId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::ProjectStatus; +use crate::models::thread_issues::ThreadIssueTarget; use crate::models::threads::{MessageBody, Thread, ThreadType}; use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; -use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; +use actix_web::{ + HttpRequest, HttpResponse, delete, get, patch, post, put, web, +}; use futures::TryStreamExt; use serde::Deserialize; use xredis::RedisPool; @@ -25,6 +29,9 @@ use xredis::RedisPool; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(thread_get_route) .service(thread_send_message_route) + .service(thread_issues_create) + .service(thread_issue_edit) + .service(thread_issue_delete) .service(message_delete_route) .service(threads_get_route); } @@ -216,14 +223,14 @@ pub async fn filter_authorized_threads( user_ids.append( &mut return_threads .iter() - .flat_map(|x| { - x.messages - .iter() - .filter_map(|x| x.author_id) - .collect::>() - }) + .flat_map(|x| x.messages.iter().filter_map(|x| x.author_id)) .collect::>(), ); + user_ids.extend( + return_threads.iter().flat_map(|thread| { + thread.issues.iter().map(|issue| issue.created_by) + }), + ); let users: Vec = database::models::DBUser::get_many_ids(&user_ids, &***pool, redis) @@ -251,6 +258,7 @@ pub async fn filter_authorized_threads( }) .collect::>(), ); + authors.extend(thread.issues.iter().map(|issue| issue.created_by)); final_threads.push(Thread::from( thread, @@ -322,6 +330,7 @@ pub async fn thread_get( }) .collect::>(), ); + authors.extend(data.issues.iter().map(|issue| issue.created_by)); let users: Vec = database::models::DBUser::get_many_ids(authors, &**pool, &redis) @@ -394,6 +403,337 @@ pub async fn threads_get( Ok(HttpResponse::Ok().json(threads)) } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssue { + pub what: ThreadIssueTarget, + pub why: serde_json::Value, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssues { + pub issues: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct EditThreadIssue { + pub user_addressed: Option, + pub moderator_verified: Option, +} + +async fn thread_project_id( + thread_id: database::models::DBThreadId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query_scalar!( + "SELECT mod_id FROM threads WHERE id = $1", + thread_id as database::models::DBThreadId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread project")? + .flatten() + .map(database::models::DBProjectId)) +} + +async fn thread_issue_project_id( + issue_id: database::models::DBThreadIssueId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query_scalar!( + r#" + SELECT thread.mod_id + FROM threads_issues issue + INNER JOIN threads thread ON thread.id = issue.thread_id + WHERE issue.id = $1 + "#, + issue_id as database::models::DBThreadIssueId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread issue project")? + .flatten() + .map(database::models::DBProjectId)) +} + +async fn project_exists( + project_id: database::models::DBProjectId, + pool: &PgPool, +) -> Result { + Ok(sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1) AS "exists!""#, + project_id as database::models::DBProjectId, + ) + .fetch_one(pool) + .await + .wrap_internal_err("checking whether thread issue project exists")?) +} + +async fn is_project_team_member( + project_id: database::models::DBProjectId, + user_id: database::models::DBUserId, + transaction: &mut database::PgTransaction<'_>, +) -> Result { + Ok(sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM mods project + INNER JOIN team_members member + ON member.team_id = project.team_id + WHERE + project.id = $1 + AND member.user_id = $2 + AND member.accepted + UNION + SELECT 1 + FROM mods project + INNER JOIN organizations organization + ON organization.id = project.organization_id + INNER JOIN team_members member + ON member.team_id = organization.team_id + WHERE + project.id = $1 + AND member.user_id = $2 + AND member.accepted + ) AS "exists!" + "#, + project_id as database::models::DBProjectId, + user_id as database::models::DBUserId, + ) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("checking project team membership")?) +} + +async fn prepare_thread_issue_mutation( + project_id: database::models::DBProjectId, + transaction: &mut database::PgTransaction<'_>, +) -> Result<(), ApiError> { + crate::routes::internal::delphi::tech_review_queue::remove_projects_without_details( + &[project_id], + crate::routes::internal::delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, + transaction, + ) + .await + .wrap_api_err("synchronizing project technical review state")?; + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("locking project thread issues")?; + + Ok(()) +} + +#[utoipa::path( + tag = "threads", + request_body = NewThreadIssues, + responses((status = NO_CONTENT)) +)] +#[put("/thread/{id}/issue")] +pub async fn thread_issues_create( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(new_issues): web::Json, +) -> Result<(), ApiError> { + let moderator = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + if new_issues.issues.is_empty() { + return Err(ApiError::Request(eyre::eyre!( + "must provide at least one thread issue" + ))); + } + + let thread_id: database::models::DBThreadId = info.into_inner().0.into(); + let project_id = thread_project_id(thread_id, &**pool) + .await? + .wrap_not_found_err("resource not found")?; + if !project_exists(project_id, &**pool).await? { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + + database::models::DBThreadIssue::insert_many( + thread_id, + moderator.id.into(), + new_issues + .issues + .into_iter() + .map(|issue| ThreadIssueBuilder { + what: issue.what, + why: issue.why, + }) + .collect(), + &mut transaction, + ) + .await + .wrap_internal_err("creating thread issues")?; + + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + ) + .await +} + +#[utoipa::path( + tag = "threads", + request_body = EditThreadIssue, + responses((status = NO_CONTENT)) +)] +#[patch("/thread/issue/{id}")] +pub async fn thread_issue_edit( + req: HttpRequest, + info: web::Path<(ThreadIssueId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(edit): web::Json, +) -> Result<(), ApiError> { + if edit.user_addressed.is_none() && edit.moderator_verified.is_none() { + return Err(ApiError::Request(eyre::eyre!( + "must provide a thread issue field to update" + ))); + } + if edit.moderator_verified == Some(false) { + return Err(ApiError::Request(eyre::eyre!( + "a verified thread issue cannot be unverified" + ))); + } + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")? + .1; + let issue_id: database::models::DBThreadIssueId = + info.into_inner().0.into(); + let project_id = thread_issue_project_id(issue_id, &**pool) + .await? + .wrap_not_found_err("resource not found")?; + let project_exists = project_exists(project_id, &**pool).await?; + + if edit.moderator_verified.is_some() && !user.role.is_mod() { + return Err(ApiError::Auth(eyre::eyre!( + "only moderators can verify thread issues" + ))); + } + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + if project_exists { + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + } + if edit.user_addressed.is_some() + && !is_project_team_member(project_id, user.id.into(), &mut transaction) + .await? + { + return Err(ApiError::Auth(eyre::eyre!( + "only project team members can address thread issues" + ))); + } + let updated = database::models::DBThreadIssue::update_flags( + issue_id, + edit.user_addressed, + edit.moderator_verified, + &mut transaction, + ) + .await + .wrap_internal_err("updating thread issue")?; + if !updated { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + + if project_exists { + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + ) + .await + } else { + transaction + .commit() + .await + .wrap_internal_err("committing thread issue update") + } +} + +#[utoipa::path(tag = "threads", responses((status = NO_CONTENT)))] +#[delete("/thread/issue/{id}")] +pub async fn thread_issue_delete( + req: HttpRequest, + info: web::Path<(ThreadIssueId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let issue_id: database::models::DBThreadIssueId = + info.into_inner().0.into(); + let project_id = thread_issue_project_id(issue_id, &**pool) + .await? + .wrap_not_found_err("resource not found")?; + let project_exists = project_exists(project_id, &**pool).await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + if project_exists { + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + } + if !database::models::DBThreadIssue::remove(issue_id, &mut transaction) + .await + .wrap_internal_err("deleting thread issue")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + + if project_exists { + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + ) + .await + } else { + transaction + .commit() + .await + .wrap_internal_err("committing thread issue deletion") + } +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct NewThreadMessage { pub body: MessageBody, From 0d5057b137a7fb2e96b38f65d681f6adb35682a3 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:56:39 +0100 Subject: [PATCH 08/25] fix up routes --- ...43dc3b3e4c93605769fd7d653beff95bf46e0.json | 28 ++++++++ ...d15a3e6c3d319d605bce5460e4232db0a2075.json | 19 +++++ ...0ade0cde67da2111cf88096c875476a4d297e.json | 14 ++++ ...5a0a06fbe12c8090d2b4769d2731c74d13c86.json | 70 +++++++++++++++++++ ...3caf7d41faa3844353d5d5f3947d6c5434ebd.json | 22 ++++++ ...466a348cbed0cfc2447524c04e0f64f06029.json} | 22 +++--- ...b7abe942cdbf08743b35d02e2b922862f7c6a.json | 22 ++++++ ...ce7eab8a066d0d90f93e19abf62cad6b22eeb.json | 22 ++++++ ...4e9f1b258aa9c878e22c35114a06140a1aa3d.json | 35 ++++++++++ ...3e2b9bd706df88ea5cfe892cd0972afd37ced.json | 17 +++++ ...2802124213b42de9b588e05b4f40bdb9392db.json | 70 +++++++++++++++++++ ...dcc9d963cc033582bf2e945e8bf3a301b4247.json | 22 ++++++ ...a0af688631d1ea349314e62e4556a9ad2cb1c.json | 22 ++++++ ...a3b5c06a19502763a2972af8f1018f16f61f4.json | 22 ++++++ ...0a204b8335ac234a74ad75b3a00dc7d87946d.json | 15 ++++ ...c0f064792b9314f737fe86c85f7527a83aa68.json | 23 ++++++ ...8c53b575aed95305d47b99c38fce2fddf2475.json | 22 ++++++ apps/labrinth/src/models/v3/thread_issues.rs | 23 +++--- apps/labrinth/src/routes/v3/mod.rs | 10 +-- apps/labrinth/src/routes/v3/threads.rs | 20 +++--- 20 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json create mode 100644 apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json create mode 100644 apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json create mode 100644 apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json create mode 100644 apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json rename apps/labrinth/.sqlx/{query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json => query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json} (66%) create mode 100644 apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json create mode 100644 apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json create mode 100644 apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json create mode 100644 apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json create mode 100644 apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json create mode 100644 apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json create mode 100644 apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json create mode 100644 apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json create mode 100644 apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json create mode 100644 apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json create mode 100644 apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json diff --git a/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json b/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json new file mode 100644 index 00000000000..27d705733c9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT status, slug\n FROM mods\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0" +} diff --git a/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json b/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json new file mode 100644 index 00000000000..8c95868844b --- /dev/null +++ b/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n what,\n why,\n user_addressed,\n moderator_verified,\n verdict\n )\n VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Jsonb", + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075" +} diff --git a/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json b/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json new file mode 100644 index 00000000000..13675d58692 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM threads_issues WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e" +} diff --git a/apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json b/apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json new file mode 100644 index 00000000000..d427477a2e6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n thread_id,\n created_by,\n what AS \"what: Json\",\n why,\n user_addressed,\n moderator_verified,\n verdict,\n created_at\n FROM threads_issues\n WHERE thread_id = ANY($1)\n ORDER BY created_at, id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "verdict", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86" +} diff --git a/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json b/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json new file mode 100644 index 00000000000..56e158f8f54 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mod_id FROM threads WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd" +} diff --git a/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json b/apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json similarity index 66% rename from apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json rename to apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json index 18441a79506..a5ce31f7227 100644 --- a/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json +++ b/apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering\n FROM mods_gallery mg\n INNER JOIN mods m ON mg.mod_id = m.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "query": "\n SELECT DISTINCT mod_id, mg.id gallery_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering\n FROM mods_gallery mg\n INNER JOIN mods m ON mg.mod_id = m.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", "describe": { "columns": [ { @@ -10,36 +10,41 @@ }, { "ordinal": 1, + "name": "gallery_id", + "type_info": "Int4" + }, + { + "ordinal": 2, "name": "image_url", "type_info": "Varchar" }, { - "ordinal": 2, + "ordinal": 3, "name": "raw_image_url", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "featured", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "name", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 6, "name": "description", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "created", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "ordering", "type_info": "Int8" } @@ -54,6 +59,7 @@ false, false, false, + false, true, true, true, @@ -61,5 +67,5 @@ false ] }, - "hash": "ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29" + "hash": "48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029" } diff --git a/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json b/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json new file mode 100644 index 00000000000..c9ed0370678 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM mods\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a" +} diff --git a/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json b/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json new file mode 100644 index 00000000000..b66117e82bc --- /dev/null +++ b/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT thread.mod_id\n FROM threads_issues issue\n INNER JOIN threads thread ON thread.id = issue.thread_id\n WHERE issue.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb" +} diff --git a/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json b/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json new file mode 100644 index 00000000000..a510cd7a235 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT team_id, user_id, role\n FROM team_members\n WHERE\n team_id = $1\n OR team_id = (\n SELECT team_id\n FROM organizations\n WHERE id = $2\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d" +} diff --git a/apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json b/apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json new file mode 100644 index 00000000000..bce94df710a --- /dev/null +++ b/apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issues\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified),\n verdict = CASE\n WHEN $3 IS TRUE THEN $4\n ELSE verdict\n END\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced" +} diff --git a/apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json b/apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json new file mode 100644 index 00000000000..a0594d3a926 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT\n\t\t\t\tid,\n\t\t\t\tthread_id,\n\t\t\t\tcreated_by,\n\t\t\t\twhat AS \"what: Json\",\n\t\t\t\twhy,\n\t\t\t\tuser_addressed,\n\t\t\t\tmoderator_verified,\n\t\t\t\tverdict,\n\t\t\t\tcreated_at\n\t\t\tFROM threads_issues\n\t\t\tWHERE thread_id = $1\n\t\t\tORDER BY id\n\t\t\tFOR UPDATE\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "verdict", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db" +} diff --git a/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json b/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json new file mode 100644 index 00000000000..909e6ad42df --- /dev/null +++ b/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247" +} diff --git a/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json b/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json new file mode 100644 index 00000000000..4fba8cbe7e6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c" +} diff --git a/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json b/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json new file mode 100644 index 00000000000..b360508fb07 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM mods\n WHERE organization_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4" +} diff --git a/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json b/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json new file mode 100644 index 00000000000..97ec15e4058 --- /dev/null +++ b/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\t\t\tUPDATE threads_issues\n\t\t\t\t\tSET verdict = $2\n\t\t\t\t\tWHERE id = $1\n\t\t\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d" +} diff --git a/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json b/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json new file mode 100644 index 00000000000..3f6abd4f4aa --- /dev/null +++ b/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS (\n SELECT 1\n FROM mods project\n INNER JOIN team_members member\n ON member.team_id = project.team_id\n WHERE\n project.id = $1\n AND member.user_id = $2\n AND member.accepted\n UNION\n SELECT 1\n FROM mods project\n INNER JOIN organizations organization\n ON organization.id = project.organization_id\n INNER JOIN team_members member\n ON member.team_id = organization.team_id\n WHERE\n project.id = $1\n AND member.user_id = $2\n AND member.accepted\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68" +} diff --git a/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json b/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json new file mode 100644 index 00000000000..d78be20a533 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issues WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475" +} diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index fe3417d66a4..5999356d659 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -1100,17 +1100,18 @@ mod tests { Some(Some("Suggested note")), ), }; - let value_state = |target: &ThreadIssueTarget, disclosures| { - target.value_state( - &ThreadIssueContext { - project: &project, - versions: &[], - disclosures, - team_members: &[], - }, - false, - ) - }; + let value_state = + |target: &ThreadIssueTarget, disclosures: &[ProjectDisclosure]| { + target.value_state( + &ThreadIssueContext { + project: &project, + versions: &[], + disclosures, + team_members: &[], + }, + false, + ) + }; assert_eq!( value_state(&remove, &disclosures), diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index bad08d3da1c..d9528aee098 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -194,11 +194,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { teams::remove_team_member_route, threads::thread_get_route, threads::threads_get_route, - threads::thread_send_message_route, - threads::thread_issues_create, - threads::thread_issue_edit, - threads::thread_issue_delete, - threads::message_delete_route, + threads::thread_send_message_route, + threads::thread_issues_create, + threads::thread_issue_edit, + threads::thread_issue_delete, + threads::message_delete_route, users::all_projects, users::admin_user_email, users::admin_user_discord, diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 98553f5959a..12848071f3b 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -459,13 +459,13 @@ async fn project_exists( project_id: database::models::DBProjectId, pool: &PgPool, ) -> Result { - Ok(sqlx::query_scalar!( + sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1) AS "exists!""#, project_id as database::models::DBProjectId, ) .fetch_one(pool) .await - .wrap_internal_err("checking whether thread issue project exists")?) + .wrap_internal_err("checking whether thread issue project exists") } async fn is_project_team_member( @@ -473,7 +473,7 @@ async fn is_project_team_member( user_id: database::models::DBUserId, transaction: &mut database::PgTransaction<'_>, ) -> Result { - Ok(sqlx::query_scalar!( + sqlx::query_scalar!( r#" SELECT EXISTS ( SELECT 1 @@ -502,7 +502,7 @@ async fn is_project_team_member( ) .fetch_one(&mut *transaction) .await - .wrap_internal_err("checking project team membership")?) + .wrap_internal_err("checking project team membership") } async fn prepare_thread_issue_mutation( @@ -555,10 +555,10 @@ pub async fn thread_issues_create( } let thread_id: database::models::DBThreadId = info.into_inner().0.into(); - let project_id = thread_project_id(thread_id, &**pool) + let project_id = thread_project_id(thread_id, &pool) .await? .wrap_not_found_err("resource not found")?; - if !project_exists(project_id, &**pool).await? { + if !project_exists(project_id, &pool).await? { return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let mut transaction = pool @@ -628,10 +628,10 @@ pub async fn thread_issue_edit( .1; let issue_id: database::models::DBThreadIssueId = info.into_inner().0.into(); - let project_id = thread_issue_project_id(issue_id, &**pool) + let project_id = thread_issue_project_id(issue_id, &pool) .await? .wrap_not_found_err("resource not found")?; - let project_exists = project_exists(project_id, &**pool).await?; + let project_exists = project_exists(project_id, &pool).await?; if edit.moderator_verified.is_some() && !user.role.is_mod() { return Err(ApiError::Auth(eyre::eyre!( @@ -701,10 +701,10 @@ pub async fn thread_issue_delete( let issue_id: database::models::DBThreadIssueId = info.into_inner().0.into(); - let project_id = thread_issue_project_id(issue_id, &**pool) + let project_id = thread_issue_project_id(issue_id, &pool) .await? .wrap_not_found_err("resource not found")?; - let project_exists = project_exists(project_id, &**pool).await?; + let project_exists = project_exists(project_id, &pool).await?; let mut transaction = pool .begin() .await From 6631b19f58c6624b9ae2a17c227af2a53c0a9c4b Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:11:53 +0100 Subject: [PATCH 09/25] thread issue facets --- ...d15a3e6c3d319d605bce5460e4232db0a2075.json | 19 -- ...0ea887db325063d1c3c310afa523f9cb74be0.json | 17 ++ ...be69a561a4f267537ac69248f603e7e20a2f0.json | 22 ++ ...9a71fc88f9e779f8d84be372430269c2b06c3.json | 17 ++ ...77d104c8edd006d2e847486c9c87c10f8771.json} | 7 +- ...951a4860fb3c94ab9a7b2686a68084fb830c.json} | 30 ++- ...4f6850842a93071bf9ed776dbef216e3c3812.json | 22 ++ ...457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json} | 30 ++- ...f29615bd28598149d84b79a58966da9128875.json | 15 ++ ...0a204b8335ac234a74ad75b3a00dc7d87946d.json | 15 -- ...ad484a6d41f40aea6699f7be73963b80a064a.json | 15 ++ .../20260916120000_thread_issues.sql | 16 +- apps/labrinth/src/database/models/ids.rs | 8 +- .../src/database/models/thread_issue_item.rs | 251 ++++++++++++------ apps/labrinth/src/models/v3/ids.rs | 1 + apps/labrinth/src/models/v3/thread_issues.rs | 96 +++++-- apps/labrinth/src/routes/v3/threads.rs | 22 +- 17 files changed, 443 insertions(+), 160 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json create mode 100644 apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json create mode 100644 apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json create mode 100644 apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json rename apps/labrinth/.sqlx/{query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json => query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json} (55%) rename apps/labrinth/.sqlx/{query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json => query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json} (57%) create mode 100644 apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json rename apps/labrinth/.sqlx/{query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json => query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json} (57%) create mode 100644 apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json delete mode 100644 apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json create mode 100644 apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json diff --git a/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json b/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json deleted file mode 100644 index 8c95868844b..00000000000 --- a/apps/labrinth/.sqlx/query-0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n what,\n why,\n user_addressed,\n moderator_verified,\n verdict\n )\n VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, $6)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Jsonb", - "Jsonb", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "0a4e4aa2135210427c6e8841586d15a3e6c3d319d605bce5460e4232db0a2075" -} diff --git a/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json b/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json new file mode 100644 index 00000000000..4cd491776d0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n why,\n user_addressed,\n moderator_verified\n )\n VALUES ($1, $2, $3, $4, FALSE, FALSE)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0" +} diff --git a/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json b/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json new file mode 100644 index 00000000000..c048c122703 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issue_facets WHERE id = ANY($1))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0" +} diff --git a/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json b/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json new file mode 100644 index 00000000000..974ddb9f301 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issue_facets (\n id,\n issue_id,\n what,\n verdict\n )\n SELECT facet_id, $1, what, verdict\n FROM UNNEST(\n $2::int8[],\n $3::jsonb[],\n $4::varchar[]\n ) AS facet(facet_id, what, verdict)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8Array", + "JsonbArray", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3" +} diff --git a/apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json b/apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json similarity index 55% rename from apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json rename to apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json index bce94df710a..0e85e8a6854 100644 --- a/apps/labrinth/.sqlx/query-8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced.json +++ b/apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json @@ -1,17 +1,16 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE threads_issues\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified),\n verdict = CASE\n WHEN $3 IS TRUE THEN $4\n ELSE verdict\n END\n WHERE id = $1\n ", + "query": "\n UPDATE threads_issues\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified)\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", "Bool", - "Bool", - "Varchar" + "Bool" ] }, "nullable": [] }, - "hash": "8f3d96a6cc3e4fa6c61c4978d663e2b9bd706df88ea5cfe892cd0972afd37ced" + "hash": "8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771" } diff --git a/apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json b/apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json similarity index 57% rename from apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json rename to apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json index a0594d3a926..bbee4c6179d 100644 --- a/apps/labrinth/.sqlx/query-8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db.json +++ b/apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT\n\t\t\t\tid,\n\t\t\t\tthread_id,\n\t\t\t\tcreated_by,\n\t\t\t\twhat AS \"what: Json\",\n\t\t\t\twhy,\n\t\t\t\tuser_addressed,\n\t\t\t\tmoderator_verified,\n\t\t\t\tverdict,\n\t\t\t\tcreated_at\n\t\t\tFROM threads_issues\n\t\t\tWHERE thread_id = $1\n\t\t\tORDER BY id\n\t\t\tFOR UPDATE\n\t\t\t", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n issue.user_addressed,\n issue.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = $1\n ORDER BY issue.id, facet.id\n FOR UPDATE OF issue, facet\n ", "describe": { "columns": [ { @@ -20,33 +20,38 @@ }, { "ordinal": 3, - "name": "what: Json", + "name": "why", "type_info": "Jsonb" }, { "ordinal": 4, - "name": "why", - "type_info": "Jsonb" + "name": "user_addressed", + "type_info": "Bool" }, { "ordinal": 5, - "name": "user_addressed", + "name": "moderator_verified", "type_info": "Bool" }, { "ordinal": 6, - "name": "moderator_verified", - "type_info": "Bool" + "name": "created_at", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "verdict", - "type_info": "Varchar" + "name": "facet_id!", + "type_info": "Int8" }, { "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" } ], "parameters": { @@ -63,8 +68,9 @@ false, false, false, + false, false ] }, - "hash": "8f6f5e95466657d94b77e6440fd2802124213b42de9b588e05b4f40bdb9392db" + "hash": "8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c" } diff --git a/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json b/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json new file mode 100644 index 00000000000..967e6f9c24b --- /dev/null +++ b/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issue_facets WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812" +} diff --git a/apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json b/apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json similarity index 57% rename from apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json rename to apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json index d427477a2e6..f2bad01c7f5 100644 --- a/apps/labrinth/.sqlx/query-2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86.json +++ b/apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n thread_id,\n created_by,\n what AS \"what: Json\",\n why,\n user_addressed,\n moderator_verified,\n verdict,\n created_at\n FROM threads_issues\n WHERE thread_id = ANY($1)\n ORDER BY created_at, id\n ", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n issue.user_addressed,\n issue.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = ANY($1)\n ORDER BY issue.created_at, issue.id, facet.id\n ", "describe": { "columns": [ { @@ -20,33 +20,38 @@ }, { "ordinal": 3, - "name": "what: Json", + "name": "why", "type_info": "Jsonb" }, { "ordinal": 4, - "name": "why", - "type_info": "Jsonb" + "name": "user_addressed", + "type_info": "Bool" }, { "ordinal": 5, - "name": "user_addressed", + "name": "moderator_verified", "type_info": "Bool" }, { "ordinal": 6, - "name": "moderator_verified", - "type_info": "Bool" + "name": "created_at", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "verdict", - "type_info": "Varchar" + "name": "facet_id!", + "type_info": "Int8" }, { "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" } ], "parameters": { @@ -63,8 +68,9 @@ false, false, false, + false, false ] }, - "hash": "2dd6a62747b48010c533fbce7f85a0a06fbe12c8090d2b4769d2731c74d13c86" + "hash": "b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6" } diff --git a/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json b/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json new file mode 100644 index 00000000000..02e5f0ce04c --- /dev/null +++ b/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET verdict = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875" +} diff --git a/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json b/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json deleted file mode 100644 index 97ec15e4058..00000000000 --- a/apps/labrinth/.sqlx/query-be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\t\t\tUPDATE threads_issues\n\t\t\t\t\tSET verdict = $2\n\t\t\t\t\tWHERE id = $1\n\t\t\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "be5cbc210ef477063d018ff16960a204b8335ac234a74ad75b3a00dc7d87946d" -} diff --git a/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json b/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json new file mode 100644 index 00000000000..8127ae5dde3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET verdict = $2\n WHERE issue_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a" +} diff --git a/apps/labrinth/migrations/20260916120000_thread_issues.sql b/apps/labrinth/migrations/20260916120000_thread_issues.sql index f5814a7432b..66ce4480d89 100644 --- a/apps/labrinth/migrations/20260916120000_thread_issues.sql +++ b/apps/labrinth/migrations/20260916120000_thread_issues.sql @@ -3,12 +3,20 @@ CREATE TABLE threads_issues ( thread_id BIGINT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, created_by BIGINT NOT NULL REFERENCES users(id) ON UPDATE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - what JSONB NOT NULL, why JSONB NOT NULL, user_addressed BOOLEAN NOT NULL DEFAULT FALSE, - moderator_verified BOOLEAN NOT NULL DEFAULT FALSE, + moderator_verified BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX threads_issues_thread_id + ON threads_issues(thread_id); + +CREATE TABLE threads_issue_facets ( + id BIGINT PRIMARY KEY, + issue_id BIGINT NOT NULL REFERENCES threads_issues(id) ON DELETE CASCADE, + what JSONB NOT NULL, verdict VARCHAR(64) NOT NULL ); -CREATE INDEX threads_issues_thread_id_verdict - ON threads_issues(thread_id, verdict); +CREATE INDEX threads_issue_facets_issue_id_id + ON threads_issue_facets(issue_id, id); diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index a06ef5f34e9..660a9d7f9ba 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -6,7 +6,8 @@ use crate::models::ids::{ OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId, - ThreadId, ThreadIssueId, ThreadMessageId, UserSubscriptionId, VersionId, + ThreadId, ThreadIssueFacetId, ThreadIssueId, ThreadMessageId, + UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -249,6 +250,11 @@ db_id_interface!( ThreadId, generator: generate_thread_id @ "threads", ); +db_id_interface!( + ThreadIssueFacetId, + generator: generate_thread_issue_facet_id @ "threads_issue_facets", + bulk_generator: generate_many_thread_issue_facet_ids, +); db_id_interface!( ThreadIssueId, generator: generate_thread_issue_id @ "threads_issues", diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs index 953b304a353..c824d51ec6a 100644 --- a/apps/labrinth/src/database/models/thread_issue_item.rs +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -1,9 +1,10 @@ use super::ids::{ - DBThreadId, DBThreadIssueId, DBUserId, generate_thread_issue_id, + DBThreadId, DBThreadIssueFacetId, DBThreadIssueId, DBUserId, + generate_many_thread_issue_facet_ids, generate_thread_issue_id, }; use crate::database::PgTransaction; use crate::models::thread_issues::{ - ThreadIssueContext, ThreadIssueTarget, ThreadIssueVerdict, + ThreadIssueContext, ThreadIssueFacet, ThreadIssueTarget, ThreadIssueVerdict, }; use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr, eyre}; @@ -11,7 +12,7 @@ use serde::{Deserialize, Serialize}; use sqlx::types::Json; pub struct ThreadIssueBuilder { - pub what: ThreadIssueTarget, + pub facets: Vec, pub why: serde_json::Value, } @@ -20,10 +21,10 @@ pub struct DBThreadIssue { pub id: DBThreadIssueId, pub thread_id: DBThreadId, pub created_by: DBUserId, - pub what: ThreadIssueTarget, pub why: serde_json::Value, pub user_addressed: bool, pub moderator_verified: bool, + pub facets: Vec, pub verdict: ThreadIssueVerdict, pub created_at: DateTime, } @@ -44,18 +45,21 @@ impl DBThreadIssue { let rows = sqlx::query!( r#" SELECT - id, - thread_id, - created_by, - what AS "what: Json", - why, - user_addressed, - moderator_verified, - verdict, - created_at - FROM threads_issues - WHERE thread_id = ANY($1) - ORDER BY created_at, id + issue.id, + issue.thread_id, + issue.created_by, + issue.why, + issue.user_addressed, + issue.moderator_verified, + issue.created_at, + facet.id AS "facet_id!", + facet.what AS "what: Json", + facet.verdict + FROM threads_issues issue + INNER JOIN threads_issue_facets facet + ON facet.issue_id = issue.id + WHERE issue.thread_id = ANY($1) + ORDER BY issue.created_at, issue.id, facet.id "#, &thread_ids, ) @@ -63,21 +67,42 @@ impl DBThreadIssue { .await .wrap_err("fetching thread issues")?; - rows.into_iter() - .map(|row| { - Ok(Self { + let mut issues = Vec::::new(); + for row in rows { + let facet_verdict = + parse_verdict(row.id, row.facet_id, &row.verdict)?; + let what = row.what.0; + + if issues.last().is_none_or(|issue| issue.id.0 != row.id) { + issues.push(Self { id: DBThreadIssueId(row.id), thread_id: DBThreadId(row.thread_id), created_by: DBUserId(row.created_by), - what: row.what.0, why: row.why, user_addressed: row.user_addressed, moderator_verified: row.moderator_verified, - verdict: parse_verdict(row.id, &row.verdict)?, + facets: Vec::new(), + verdict: ThreadIssueVerdict::Open, created_at: row.created_at, - }) - }) - .collect() + }); + } + + issues + .last_mut() + .expect("thread issue was inserted above") + .facets + .push(ThreadIssueFacet { + id: DBThreadIssueFacetId(row.facet_id).into(), + what, + verdict: facet_verdict, + }); + } + + for issue in &mut issues { + issue.verdict = ThreadIssueVerdict::from_facets(&issue.facets); + } + + Ok(issues) } pub async fn insert_many( @@ -86,13 +111,15 @@ impl DBThreadIssue { issues: Vec, transaction: &mut PgTransaction<'_>, ) -> Result> { + if issues.iter().any(|issue| issue.facets.is_empty()) { + return Err(eyre!("thread issues must contain at least one facet")); + } + let mut ids = Vec::with_capacity(issues.len()); for issue in issues { let id = generate_thread_issue_id(&mut *transaction) .await .wrap_err("generating thread issue ID")?; - let what = serde_json::to_value(issue.what) - .wrap_err("serializing thread issue target")?; sqlx::query!( r#" @@ -100,25 +127,63 @@ impl DBThreadIssue { id, thread_id, created_by, - what, why, user_addressed, - moderator_verified, - verdict + moderator_verified ) - VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, $6) + VALUES ($1, $2, $3, $4, FALSE, FALSE) "#, id as DBThreadIssueId, thread_id as DBThreadId, created_by as DBUserId, - what, issue.why, - ThreadIssueVerdict::Open.as_str(), ) .execute(&mut *transaction) .await .wrap_err("inserting thread issue")?; + let facet_ids = generate_many_thread_issue_facet_ids( + issue.facets.len(), + &mut *transaction, + ) + .await + .wrap_err("generating thread issue facet IDs")?; + let facet_ids = facet_ids.iter().map(|id| id.0).collect::>(); + let whats = issue + .facets + .into_iter() + .map(serde_json::to_value) + .collect::, _>>() + .wrap_err("serializing thread issue facets")?; + let verdicts = facet_ids + .iter() + .map(|_| ThreadIssueVerdict::Open.as_str().to_string()) + .collect::>(); + + sqlx::query!( + r#" + INSERT INTO threads_issue_facets ( + id, + issue_id, + what, + verdict + ) + SELECT facet_id, $1, what, verdict + FROM UNNEST( + $2::int8[], + $3::jsonb[], + $4::varchar[] + ) AS facet(facet_id, what, verdict) + "#, + id as DBThreadIssueId, + &facet_ids, + &whats, + &verdicts, + ) + .execute(&mut *transaction) + .await + .wrap_err("inserting thread issue facets")?; + ids.push(id); } @@ -136,22 +201,32 @@ impl DBThreadIssue { UPDATE threads_issues SET user_addressed = COALESCE($2, user_addressed), - moderator_verified = COALESCE($3, moderator_verified), - verdict = CASE - WHEN $3 IS TRUE THEN $4 - ELSE verdict - END + moderator_verified = COALESCE($3, moderator_verified) WHERE id = $1 "#, id as DBThreadIssueId, user_addressed, moderator_verified, - ThreadIssueVerdict::Resolved.as_str(), ) .execute(&mut *transaction) .await .wrap_err("updating thread issue")?; + if result.rows_affected() > 0 && moderator_verified == Some(true) { + sqlx::query!( + r#" + UPDATE threads_issue_facets + SET verdict = $2 + WHERE issue_id = $1 + "#, + id as DBThreadIssueId, + ThreadIssueVerdict::Resolved.as_str(), + ) + .execute(&mut *transaction) + .await + .wrap_err("resolving verified thread issue facets")?; + } + Ok(result.rows_affected() > 0) } @@ -176,21 +251,24 @@ impl DBThreadIssue { ) -> Result> { let rows = sqlx::query!( r#" - SELECT - id, - thread_id, - created_by, - what AS "what: Json", - why, - user_addressed, - moderator_verified, - verdict, - created_at - FROM threads_issues - WHERE thread_id = $1 - ORDER BY id - FOR UPDATE - "#, + SELECT + issue.id, + issue.thread_id, + issue.created_by, + issue.why, + issue.user_addressed, + issue.moderator_verified, + issue.created_at, + facet.id AS "facet_id!", + facet.what AS "what: Json", + facet.verdict + FROM threads_issues issue + INNER JOIN threads_issue_facets facet + ON facet.issue_id = issue.id + WHERE issue.thread_id = $1 + ORDER BY issue.id, facet.id + FOR UPDATE OF issue, facet + "#, DBThreadId::from(context.project.thread_id) as DBThreadId, ) .fetch_all(&mut *transaction) @@ -199,9 +277,10 @@ impl DBThreadIssue { "locking project thread issues for verdict synchronization", )?; - let mut issues = Vec::with_capacity(rows.len()); + let mut issues = Vec::::new(); for row in rows { - let stored_verdict = parse_verdict(row.id, &row.verdict)?; + let stored_verdict = + parse_verdict(row.id, row.facet_id, &row.verdict)?; let what = row.what.0; let verdict = what.verdict( context, @@ -212,42 +291,62 @@ impl DBThreadIssue { if verdict != stored_verdict { sqlx::query!( r#" - UPDATE threads_issues - SET verdict = $2 - WHERE id = $1 - "#, - row.id, + UPDATE threads_issue_facets + SET verdict = $2 + WHERE id = $1 + "#, + row.facet_id, verdict.as_str(), ) .execute(&mut *transaction) .await - .wrap_err("updating project thread issue verdict")?; + .wrap_err("updating project thread issue facet verdict")?; + } + + if issues.last().is_none_or(|issue| issue.id.0 != row.id) { + issues.push(Self { + id: DBThreadIssueId(row.id), + thread_id: DBThreadId(row.thread_id), + created_by: DBUserId(row.created_by), + why: row.why, + user_addressed: row.user_addressed, + moderator_verified: row.moderator_verified, + facets: Vec::new(), + verdict: ThreadIssueVerdict::Open, + created_at: row.created_at, + }); } - issues.push(Self { - id: DBThreadIssueId(row.id), - thread_id: DBThreadId(row.thread_id), - created_by: DBUserId(row.created_by), - what, - why: row.why, - user_addressed: row.user_addressed, - moderator_verified: row.moderator_verified, - verdict, - created_at: row.created_at, - }); + issues + .last_mut() + .expect("thread issue was inserted above") + .facets + .push(ThreadIssueFacet { + id: DBThreadIssueFacetId(row.facet_id).into(), + what, + verdict, + }); + } + + for issue in &mut issues { + issue.verdict = ThreadIssueVerdict::from_facets(&issue.facets); } Ok(issues) } } -fn parse_verdict(id: i64, verdict: &str) -> Result { +fn parse_verdict( + issue_id: i64, + facet_id: i64, + verdict: &str, +) -> Result { match verdict { "open" => Ok(ThreadIssueVerdict::Open), "addressed" => Ok(ThreadIssueVerdict::Addressed), "resolved" => Ok(ThreadIssueVerdict::Resolved), - verdict => { - Err(eyre!("thread issue `{id}` has invalid verdict `{verdict}`")) - } + verdict => Err(eyre!( + "thread issue `{issue_id}` facet `{facet_id}` has invalid verdict `{verdict}`" + )), } } diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 9e39e93e92e..9c1a60d6491 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -36,6 +36,7 @@ base62_id!(SessionId); base62_id!(TeamId); base62_id!(TeamMemberId); base62_id!(ThreadId); +base62_id!(ThreadIssueFacetId); base62_id!(ThreadIssueId); base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 5999356d659..cd3b4ba9929 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -1,7 +1,8 @@ use crate::models::disclosures::ProjectDisclosure; use crate::models::exp::minecraft::Language; use crate::models::ids::{ - FileId, GalleryImageId, TeamId, ThreadIssueId, VersionId, + FileId, GalleryImageId, TeamId, ThreadIssueFacetId, ThreadIssueId, + VersionId, }; use crate::models::projects::{Dependency, FileType, Project, Version}; use ariadne::ids::UserId; @@ -27,19 +28,14 @@ pub struct ThreadIssueTeamMember { /// Issue that a moderator has flagged on a project in its moderation thread. /// -/// If a moderator has a specific, targeted, actionable piece of feedback on a -/// specific part of a project (e.g. the description, summary, a gallery image, -/// etc.), they can add an issue to the moderation thread which targets that -/// part specifically, along with one of an enumerated set of reasons for why -/// the issue was applied. +/// An issue groups related actionable facets under one reason. The creator +/// addresses the issue as a whole, and a moderator verifies it as a whole. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ThreadIssue { pub id: ThreadIssueId, pub created_by: UserId, pub created_at: DateTime, - /// What part of a project this issue applies to. - pub what: ThreadIssueTarget, - /// Why the issue was raised on this part. + /// Why the issue was raised. /// /// This is treated as an opaque JSON blob by the backend; it is up to the /// frontend to define a schema for it, and to render/localize it properly. @@ -50,14 +46,27 @@ pub struct ThreadIssue { /// Has a moderator seen the user's resolution and explicitly marked it as /// resolved? /// - /// If a moderator marks this issue as verified, then it will locked to + /// If a moderator marks this issue as verified, then it will be locked to /// [`ThreadIssueVerdict::Resolved`]. pub moderator_verified: bool, - /// Final derived verdict of this issue. + /// The immutable project parts that must be changed to resolve this issue. + pub facets: Vec, + /// Verdict derived from all facet verdicts. + pub verdict: ThreadIssueVerdict, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct ThreadIssueFacet { + pub id: ThreadIssueFacetId, + /// What part of a project this facet applies to. + pub what: ThreadIssueTarget, + /// Verdict derived from the current project state and issue-level flags. pub verdict: ThreadIssueVerdict, } -/// What part of a project must change for a [`ThreadIssue`] to be resolved? +/// What part of a project must change for a [`ThreadIssueFacet`] to be resolved? #[derive( Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, )] @@ -130,7 +139,7 @@ pub enum ThreadIssueTarget { Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, )] pub struct TextTarget { - /// Original value of this field when the [`ThreadIssue`] was made. + /// Original value of this field when the [`ThreadIssueFacet`] was made. pub original: String, /// Moderator-proposed value for this field. pub suggestion: Option, @@ -210,7 +219,7 @@ pub enum ThreadIssueAcknowledgement { Reply, } -/// Current state of the action required to resolve a [`ThreadIssue`]. +/// Current state of the action required to resolve a [`ThreadIssueFacet`]. #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, )] @@ -245,6 +254,20 @@ impl ThreadIssueVerdict { Self::Resolved => "resolved", } } + + pub(crate) fn from_facets(facets: &[ThreadIssueFacet]) -> Self { + if !facets.is_empty() + && facets.iter().all(|facet| facet.verdict == Self::Resolved) + { + Self::Resolved + } else if !facets.is_empty() + && facets.iter().all(|facet| facet.verdict != Self::Open) + { + Self::Addressed + } else { + Self::Open + } + } } impl ThreadIssueTarget { @@ -724,10 +747,10 @@ impl ThreadIssue { Self { id: data.id.into(), created_by: data.created_by.into(), - what: data.what, why: data.why, user_addressed: data.user_addressed, moderator_verified: data.moderator_verified, + facets: data.facets, verdict: data.verdict, created_at: data.created_at, } @@ -1237,6 +1260,49 @@ mod tests { ); } + #[test] + fn issue_verdict_aggregates_facet_verdicts() { + let facets = |verdicts: &[ThreadIssueVerdict]| { + verdicts + .iter() + .enumerate() + .map(|(id, verdict)| ThreadIssueFacet { + id: ThreadIssueFacetId(id as u64), + what: ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Checkbox, + }, + verdict: *verdict, + }) + .collect::>() + }; + + assert_eq!( + ThreadIssueVerdict::from_facets(&[]), + ThreadIssueVerdict::Open + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + ThreadIssueVerdict::Resolved, + ThreadIssueVerdict::Resolved, + ])), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + ThreadIssueVerdict::Addressed, + ThreadIssueVerdict::Resolved, + ])), + ThreadIssueVerdict::Addressed + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + ThreadIssueVerdict::Open, + ThreadIssueVerdict::Resolved, + ])), + ThreadIssueVerdict::Open + ); + } + #[test] fn gallery_and_acknowledgement_verdicts() { let mut project = project(); diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 12848071f3b..965ecad9f2d 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -404,8 +404,13 @@ pub async fn threads_get( } #[derive(Deserialize, utoipa::ToSchema)] -pub struct NewThreadIssue { +pub struct NewThreadIssueFacet { pub what: ThreadIssueTarget, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssue { + pub facets: Vec, pub why: serde_json::Value, } @@ -553,6 +558,15 @@ pub async fn thread_issues_create( "must provide at least one thread issue" ))); } + if new_issues + .issues + .iter() + .any(|issue| issue.facets.is_empty()) + { + return Err(ApiError::Request(eyre::eyre!( + "each thread issue must have at least one facet" + ))); + } let thread_id: database::models::DBThreadId = info.into_inner().0.into(); let project_id = thread_project_id(thread_id, &pool) @@ -574,7 +588,11 @@ pub async fn thread_issues_create( .issues .into_iter() .map(|issue| ThreadIssueBuilder { - what: issue.what, + facets: issue + .facets + .into_iter() + .map(|facet| facet.what) + .collect(), why: issue.why, }) .collect(), From 22b6db1d10ac14c914d7df46a370540f2d79addd Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:59:34 +0100 Subject: [PATCH 10/25] properly handle gating a project edit --- apps/labrinth/src/models/v3/thread_issues.rs | 104 ++++++++++++------ apps/labrinth/src/models/v3/threads.rs | 6 +- .../routes/internal/moderation/tech_review.rs | 5 +- .../src/routes/v2/project_creation.rs | 11 +- apps/labrinth/src/routes/v3/disclosures.rs | 1 + apps/labrinth/src/routes/v3/organizations.rs | 3 + .../src/routes/v3/project_creation.rs | 44 +++++--- .../src/routes/v3/project_creation/new.rs | 1 + apps/labrinth/src/routes/v3/projects/mod.rs | 102 ++++++++++++----- .../src/routes/v3/projects/mutation.rs | 85 +++++++++++--- apps/labrinth/src/routes/v3/teams.rs | 4 + apps/labrinth/src/routes/v3/threads.rs | 21 +++- .../src/routes/v3/version_creation.rs | 57 +++++----- apps/labrinth/src/routes/v3/version_file.rs | 1 + apps/labrinth/src/routes/v3/versions.rs | 2 + apps/labrinth/src/util/mod.rs | 1 + apps/labrinth/src/util/non_empty_vec.rs | 85 ++++++++++++++ 17 files changed, 398 insertions(+), 135 deletions(-) create mode 100644 apps/labrinth/src/util/non_empty_vec.rs diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index cd3b4ba9929..b7039717f7e 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -5,6 +5,8 @@ use crate::models::ids::{ VersionId, }; use crate::models::projects::{Dependency, FileType, Project, Version}; +use crate::models::users::User; +use crate::util::non_empty_vec::NonEmptyVec; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -33,7 +35,7 @@ pub struct ThreadIssueTeamMember { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ThreadIssue { pub id: ThreadIssueId, - pub created_by: UserId, + pub created_by: Option, pub created_at: DateTime, /// Why the issue was raised. /// @@ -84,7 +86,7 @@ pub enum ThreadIssueTarget { original_url: Option, }, RemoveTags { - tags: Vec, + tags: NonEmptyVec, }, ModifyLinks { links: HashMap, @@ -99,10 +101,10 @@ pub enum ThreadIssueTarget { description: Option, }, RemoveGalleryImages { - image_ids: Vec, + image_ids: NonEmptyVec, }, RemoveProjectDisclosures { - disclosure_types: Vec, + disclosure_types: NonEmptyVec, }, ModifyProjectDisclosure { disclosure_type: String, @@ -183,7 +185,8 @@ pub enum VersionIssueTarget { }, ModifyChangelog(TextTarget), RemoveAdditionalFiles { - file_ids: Vec, + #[schema(value_type = Vec, min_items = 1)] + file_ids: NonEmptyVec, }, ModifyAdditionalFileType { file_id: FileId, @@ -318,15 +321,15 @@ impl ThreadIssueTarget { ); match (license_state, url_state) { - (ThreadIssueValueState::DifferentToOriginal, _) - | (_, ThreadIssueValueState::DifferentToOriginal) => { - ThreadIssueValueState::DifferentToOriginal - } - (ThreadIssueValueState::SameAsSuggested, _) - | (_, ThreadIssueValueState::SameAsSuggested) => { - ThreadIssueValueState::SameAsSuggested - } - _ => ThreadIssueValueState::SameAsOriginal, + ( + ThreadIssueValueState::SameAsOriginal, + ThreadIssueValueState::SameAsOriginal, + ) => ThreadIssueValueState::SameAsOriginal, + ( + ThreadIssueValueState::SameAsSuggested, + ThreadIssueValueState::SameAsSuggested, + ) => ThreadIssueValueState::SameAsSuggested, + _ => ThreadIssueValueState::DifferentToOriginal, } } Self::ModifyIcon { original_url } => { @@ -353,25 +356,28 @@ impl ThreadIssueTarget { } } Self::ModifyLinks { links } => { - let mut state = ThreadIssueValueState::SameAsOriginal; + let mut all_original = true; + let mut all_suggested = true; for (platform, target) in links { let current = project .link_urls .get(platform) .map(|link| link.url.as_str()) .unwrap_or_default(); - match value_state(target, current) { - ThreadIssueValueState::DifferentToOriginal => { - state = ThreadIssueValueState::DifferentToOriginal; - break; - } - ThreadIssueValueState::SameAsSuggested => { - state = ThreadIssueValueState::SameAsSuggested; - } - ThreadIssueValueState::SameAsOriginal => {} - } + let state = value_state(target, current); + all_original &= + state == ThreadIssueValueState::SameAsOriginal; + all_suggested &= + state == ThreadIssueValueState::SameAsSuggested; + } + + if all_original { + ThreadIssueValueState::SameAsOriginal + } else if all_suggested { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal } - state } Self::AddGalleryImages { original_count } => { if project.gallery.len() > *original_count as usize { @@ -743,10 +749,13 @@ fn value_state(target: &TextTarget, current: &str) -> ThreadIssueValueState { } impl ThreadIssue { - pub fn from(data: crate::database::models::DBThreadIssue) -> Self { + pub fn from( + data: crate::database::models::DBThreadIssue, + user: &User, + ) -> Self { Self { id: data.id.into(), - created_by: data.created_by.into(), + created_by: user.role.is_mod().then(|| data.created_by.into()), why: data.why, user_addressed: data.user_addressed, moderator_verified: data.moderator_verified, @@ -876,6 +885,10 @@ mod tests { } } + fn non_empty(values: Vec) -> NonEmptyVec { + values.try_into().unwrap() + } + #[test] fn nullable_suggestions_round_trip() { let target = OptionalTextTarget { @@ -937,7 +950,10 @@ mod tests { let mut project = project(); let target = ThreadIssueTarget::ModifyLicense { license: text_target("MIT", Some("Apache-2.0")), - url: text_target("https://example.com/license", None), + url: text_target( + "https://example.com/license", + Some("https://example.com/new-license"), + ), }; assert_eq!( @@ -946,6 +962,13 @@ mod tests { ); project.license.id = "Apache-2.0".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.license.url = + Some("https://example.com/new-license".to_string()); assert_eq!( target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested @@ -973,7 +996,10 @@ mod tests { ), ( "issues".to_string(), - text_target("https://example.com/issues", None), + text_target( + "https://example.com/issues", + Some("https://example.com/new-issues"), + ), ), ]), }; @@ -985,6 +1011,13 @@ mod tests { project.link_urls.get_mut("source").unwrap().url = "https://example.com/new-source".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.link_urls.get_mut("issues").unwrap().url = + "https://example.com/new-issues".to_string(); assert_eq!( target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested @@ -1005,7 +1038,10 @@ mod tests { original_url: project.icon_url.clone(), }; let tags = ThreadIssueTarget::RemoveTags { - tags: vec!["technology".to_string(), "utility".to_string()], + tags: non_empty(vec![ + "technology".to_string(), + "utility".to_string(), + ]), }; assert_eq!( @@ -1086,7 +1122,7 @@ mod tests { fn all_gallery_targets_must_be_removed() { let mut project = project(); let target = ThreadIssueTarget::RemoveGalleryImages { - image_ids: vec![GalleryImageId(1), GalleryImageId(2)], + image_ids: non_empty(vec![GalleryImageId(1), GalleryImageId(2)]), }; assert_eq!( @@ -1114,7 +1150,7 @@ mod tests { note: Some("Original note".to_string()), }]; let remove = ThreadIssueTarget::RemoveProjectDisclosures { - disclosure_types: vec!["advertisements".to_string()], + disclosure_types: non_empty(vec!["advertisements".to_string()]), }; let note = ThreadIssueTarget::ModifyProjectDisclosureNote { disclosure_type: "advertisements".to_string(), @@ -1307,7 +1343,7 @@ mod tests { fn gallery_and_acknowledgement_verdicts() { let mut project = project(); let gallery = ThreadIssueTarget::RemoveGalleryImages { - image_ids: vec![GalleryImageId(1)], + image_ids: non_empty(vec![GalleryImageId(1)]), }; let checkbox = ThreadIssueTarget::Acknowledge { mode: ThreadIssueAcknowledgement::Checkbox, diff --git a/apps/labrinth/src/models/v3/threads.rs b/apps/labrinth/src/models/v3/threads.rs index e8646269d9f..e6a23f31fb3 100644 --- a/apps/labrinth/src/models/v3/threads.rs +++ b/apps/labrinth/src/models/v3/threads.rs @@ -132,7 +132,11 @@ impl Thread { .filter(|x| user.role.is_mod() || !x.body.is_private()) .map(|x| ThreadMessage::from(x, user)) .collect(), - issues: data.issues.into_iter().map(ThreadIssue::from).collect(), + issues: data + .issues + .into_iter() + .map(|issue| ThreadIssue::from(issue, user)) + .collect(), members: users, } } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 9005e11774b..50433ce3f81 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -1254,7 +1254,10 @@ pub async fn submit_report( if verdict == DelphiVerdict::Unsafe { crate::routes::v3::projects::mutation::finalize_mutation( - project_id, txn, &redis, + project_id, + txn, + &redis, + Some(&user), ) .await?; search_state diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 8348a052d02..97ce6fe3ae8 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -112,7 +112,7 @@ struct ProjectCreateData { #[validate(nested)] pub donation_urls: Option>, - /// An optional boolean. If true, the project will be created as a draft. + /// Must be true. Projects must be submitted for review after creation. pub is_draft: Option, /// The license id that the project follows @@ -134,7 +134,7 @@ struct ProjectCreateData { pub organization_id: Option, } -/// Create a new project with initial versions. +/// Create a new project with initial versions. #[utoipa::path( tag = "project creation", post, @@ -169,6 +169,13 @@ pub async fn project_create( payload, req.headers().clone(), |legacy_create: ProjectCreateData, _| async move { + if !legacy_create.is_draft.unwrap_or(false) { + return Err(CreateError::InvalidInput( + "projects must be created as drafts and submitted for review separately" + .to_string(), + )); + } + // Side types will be applied to each version let client_side = legacy_create.client_side; let server_side = legacy_create.server_side; diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index a60ed147e70..275468314cd 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -284,6 +284,7 @@ pub async fn modify_project_disclosures( project.inner.id, transaction, &redis, + Some(&user), ) .await?; diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 3c5513278e9..48952459c10 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -867,6 +867,7 @@ pub async fn organization_delete( &organization_project_ids, transaction, &redis, + Some(&user), ) .await?; @@ -1051,6 +1052,7 @@ pub async fn organization_projects_add( project_item.inner.id, transaction, &redis, + Some(¤t_user), ) .await?; @@ -1256,6 +1258,7 @@ pub async fn organization_projects_remove( project_item.inner.id, transaction, &redis, + Some(¤t_user), ) .await?; database::models::DBUser::clear_project_cache( diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index e19469c2c10..d3890503ce1 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -21,6 +21,7 @@ use crate::models::projects::{ }; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; +use crate::models::users::User; use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::search::SearchState; @@ -326,18 +327,26 @@ pub async fn project_create_internal( let project_id: ProjectId = models::generate_project_id(&mut transaction).await?.into(); + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await? + .1; let result = project_create_inner( - req, &mut payload, &mut transaction, &**file_host, &mut uploaded_files, &client, &redis, - &session_queue, &http, project_id, + ¤t_user, ) .await; @@ -354,6 +363,7 @@ pub async fn project_create_internal( project_id.into(), transaction, &redis, + Some(¤t_user), ) .await?; search_state.queue.push_project_change(project_id).await; @@ -390,18 +400,26 @@ pub async fn project_create_with_id( let mut uploaded_files = Vec::new(); let (project_id,) = path.into_inner(); + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await? + .1; let result = project_create_inner( - req, &mut payload, &mut transaction, &**file_host, &mut uploaded_files, &client, &redis, - &session_queue, &http, project_id, + ¤t_user, ) .await; @@ -418,6 +436,7 @@ pub async fn project_create_with_id( project_id.into(), transaction, &redis, + Some(¤t_user), ) .await?; search_state.queue.push_project_change(project_id).await; @@ -460,30 +479,19 @@ Project Creation Steps: #[allow(clippy::too_many_arguments)] async fn project_create_inner( - req: HttpRequest, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, file_host: &dyn FileHost, uploaded_files: &mut Vec, pool: &PgPool, redis: &RedisPool, - session_queue: &AuthQueue, http: &reqwest::Client, project_id: ProjectId, + current_user: &User, ) -> Result { - // The currently logged in user - let (_, current_user) = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Scopes::PROJECT_CREATE, - ) - .await?; - - require_verified_email(¤t_user)?; + require_verified_email(current_user)?; - let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + let limits = UserLimits::get_for_projects(current_user, pool).await?; if limits.current >= limits.max { return Err(CreateError::LimitReached); } diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index dd99794395e..3cd214b7a76 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -344,6 +344,7 @@ pub async fn create( project_id.into(), txn, &redis, + Some(&user), ) .await?; search_state.queue.push_project_change(project_id).await; diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 3885b76573c..9c164ef55f5 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1392,6 +1392,7 @@ pub async fn project_edit_internal( project_item.inner.slug.clone(), transaction, &redis, + Some(&user), ) .await?; @@ -2108,8 +2109,13 @@ pub async fn projects_edit( .iter() .map(|(project_id, _, _)| *project_id) .collect::>(); - mutation::finalize_mutations(&changed_project_ids, transaction, &redis) - .await?; + mutation::finalize_mutations( + &changed_project_ids, + transaction, + &redis, + Some(&user), + ) + .await?; for (project_id, _, reindex_versions) in changed_projects { if reindex_versions { @@ -2305,15 +2311,6 @@ pub async fn project_icon_edit_internal( } } - delete_old_images( - project_item.inner.icon_url, - project_item.inner.raw_icon_url, - FileHostPublicity::Public, - &**file_host, - ) - .await - .wrap_api_err("deleting old images")?; - let bytes = read_limited_from_payload( &mut payload, 262144, @@ -2355,8 +2352,31 @@ pub async fn project_icon_edit_internal( .await .wrap_internal_err("querying database for `project_icon_edit_internal`")?; - mutation::finalize_mutation(project_item.inner.id, transaction, &redis) - .await?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; + + let old_icon_url = project_item + .inner + .icon_url + .filter(|url| url != &upload_result.url); + let old_raw_icon_url = project_item + .inner + .raw_icon_url + .filter(|url| url != &upload_result.raw_url); + delete_old_images( + old_icon_url, + old_raw_icon_url, + FileHostPublicity::Public, + &**file_host, + ) + .await + .wrap_api_err("deleting old images")?; + search_state .queue .push_project_change(project_item.inner.id.into()) @@ -2450,15 +2470,6 @@ pub async fn delete_project_icon_internal( } } - delete_old_images( - project_item.inner.icon_url, - project_item.inner.raw_icon_url, - FileHostPublicity::Public, - &**file_host, - ) - .await - .wrap_api_err("deleting old images")?; - let mut transaction = pool .begin() .await @@ -2478,8 +2489,23 @@ pub async fn delete_project_icon_internal( "querying database for `delete_project_icon_internal`", )?; - mutation::finalize_mutation(project_item.inner.id, transaction, &redis) - .await?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; + + delete_old_images( + project_item.inner.icon_url, + project_item.inner.raw_icon_url, + FileHostPublicity::Public, + &**file_host, + ) + .await + .wrap_api_err("deleting old images")?; + search_state .queue .push_project_change(project_item.inner.id.into()) @@ -2684,8 +2710,13 @@ pub async fn add_gallery_item_internal( .await .wrap_internal_err("inserting galleries into database")?; - mutation::finalize_mutation(project_item.inner.id, transaction, &redis) - .await?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; search_state .queue .push_project_change(project_item.inner.id.into()) @@ -2915,8 +2946,13 @@ pub async fn edit_gallery_item_internal( )?; } - mutation::finalize_mutation(project_item.inner.id, transaction, &redis) - .await?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; search_state .queue .push_project_change(project_item.inner.id.into()) @@ -3058,8 +3094,13 @@ pub async fn delete_gallery_item_internal( "querying database for `delete_gallery_item_internal`", )?; - mutation::finalize_mutation(project_item.inner.id, transaction, &redis) - .await?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; delete_old_images( Some(item.image_url), @@ -3325,6 +3366,7 @@ pub async fn project_delete_internal( original_slug, transaction, &redis, + Some(&user), ) .await?; diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 689d5d2a3c2..c8faf7ecbb3 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -2,13 +2,16 @@ use eyre::eyre; use xredis::RedisPool; use crate::database::models::project_item::ProjectQueryResult; -use crate::database::models::{DBProjectId, DBTeamId, DBThreadIssue, DBUserId}; +use crate::database::models::{ + DBProjectId, DBTeamId, DBThreadIssue, DBUserId, DBVersionId, +}; use crate::database::{PgTransaction, models as db_models}; use crate::models::ids::ProjectId; use crate::models::projects::{Project, ProjectStatus, Version}; use crate::models::thread_issues::{ ThreadIssueContext, ThreadIssueTeamMember, ThreadIssueVerdict, }; +use crate::models::users::User; use crate::routes::ApiError; use crate::routes::internal::delphi; use crate::util::error::{ApiContext as _, Context as _}; @@ -49,8 +52,19 @@ async fn sync_project_state( .await .wrap_internal_err("reloading project for state synchronization")?; let data = projects.pop().wrap_not_found_err("resource not found")?; + let project_ids = [project_id.0]; + let version_ids = sqlx::query!( + "SELECT id FROM versions WHERE mod_id = ANY($1::bigint[]) ORDER BY id", + &project_ids, + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching all project version IDs")? + .into_iter() + .map(|row| DBVersionId(row.id)) + .collect::>(); let versions = db_models::DBVersion::get_many_uncached( - &data.versions, + &version_ids, &mut *transaction, redis, ) @@ -129,14 +143,16 @@ pub(crate) async fn finalize_mutation( project_id: DBProjectId, transaction: PgTransaction<'_>, redis: &RedisPool, + project_editor: Option<&User>, ) -> Result<(), ApiError> { - finalize_mutations(&[project_id], transaction, redis).await + finalize_mutations(&[project_id], transaction, redis, project_editor).await } pub(crate) async fn finalize_mutations( project_ids: &[DBProjectId], mut transaction: PgTransaction<'_>, redis: &RedisPool, + project_editor: Option<&User>, ) -> Result<(), ApiError> { let mut project_ids = project_ids.to_vec(); project_ids.sort_by_key(|project_id| project_id.0); @@ -149,6 +165,28 @@ pub(crate) async fn finalize_mutations( ); } + if project_editor.is_some_and(|user| !user.role.is_mod()) { + for state in &states { + if state.project.status != ProjectStatus::Processing { + continue; + } + if state.has_required_nags { + return Err(ApiError::Request(eyre!( + "project cannot have required validation nags while in review" + ))); + } + if state + .thread_issues + .iter() + .any(|issue| issue.verdict == ThreadIssueVerdict::Open) + { + return Err(ApiError::Request(eyre!( + "project cannot have open moderation issues while in review" + ))); + } + } + } + transaction .commit() .await @@ -174,6 +212,7 @@ pub(crate) async fn finalize_project_edit( original_slug: Option, mut transaction: PgTransaction<'_>, redis: &RedisPool, + project_editor: Option<&User>, ) -> Result<(), ApiError> { let state = sync_project_state(project_id, &mut transaction, redis).await?; @@ -182,32 +221,52 @@ pub(crate) async fn finalize_project_edit( let entered_approved_state = state.project.status.is_approved() && !original_status.is_approved(); - if (entered_review || entered_approved_state) && state.has_required_nags { + let validate_review_state = entered_review + || project_editor.is_some_and(|user| { + !user.role.is_mod() + && state.project.status == ProjectStatus::Processing + }); + + if (validate_review_state || entered_approved_state) + && state.has_required_nags + { return Err(ApiError::Request(eyre!( - "project must have no required validation nags before review or approval" + "project must have no required validation nags before or during review or approval" ))); } - if entered_review + if validate_review_state && state .thread_issues .iter() .any(|issue| issue.verdict == ThreadIssueVerdict::Open) { return Err(ApiError::Request(eyre!( - "all open moderation issues must be addressed before review" + "project must have no open moderation issues before or during review" ))); } - if entered_approved_state - && state + if entered_approved_state { + if state .thread_issues .iter() .any(|issue| issue.verdict != ThreadIssueVerdict::Resolved) - { - return Err(ApiError::Request(eyre!( - "all moderation issues must be resolved before approval" - ))); + { + return Err(ApiError::Request(eyre!( + "all moderation issues must be resolved before approval" + ))); + } + + for issue in &state.thread_issues { + DBThreadIssue::update_flags( + issue.id, + None, + Some(true), + &mut transaction, + ) + .await + .wrap_internal_err("verifying project moderation issues")?; + } } transaction diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 377efd51637..56d9d39b5ab 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -796,6 +796,7 @@ pub async fn add_team_member( &affected_project_ids, transaction, &redis, + Some(¤t_user), ) .await?; DBTeamMember::clear_cache(team_id, &redis) @@ -1027,6 +1028,7 @@ pub async fn edit_team_member( &affected_project_ids, transaction, &redis, + Some(¤t_user), ) .await?; DBTeamMember::clear_cache(id, &redis) @@ -1246,6 +1248,7 @@ pub async fn transfer_ownership( &affected_project_ids, transaction, &redis, + Some(¤t_user), ) .await?; } else { @@ -1445,6 +1448,7 @@ pub async fn remove_team_member( &affected_project_ids, transaction, &redis, + Some(¤t_user), ) .await?; diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 965ecad9f2d..a98aa9b7a9a 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -226,11 +226,11 @@ pub async fn filter_authorized_threads( .flat_map(|x| x.messages.iter().filter_map(|x| x.author_id)) .collect::>(), ); - user_ids.extend( - return_threads.iter().flat_map(|thread| { + if user.role.is_mod() { + user_ids.extend(return_threads.iter().flat_map(|thread| { thread.issues.iter().map(|issue| issue.created_by) - }), - ); + })); + } let users: Vec = database::models::DBUser::get_many_ids(&user_ids, &***pool, redis) @@ -258,7 +258,9 @@ pub async fn filter_authorized_threads( }) .collect::>(), ); - authors.extend(thread.issues.iter().map(|issue| issue.created_by)); + if user.role.is_mod() { + authors.extend(thread.issues.iter().map(|issue| issue.created_by)); + } final_threads.push(Thread::from( thread, @@ -330,7 +332,9 @@ pub async fn thread_get( }) .collect::>(), ); - authors.extend(data.issues.iter().map(|issue| issue.created_by)); + if user.role.is_mod() { + authors.extend(data.issues.iter().map(|issue| issue.created_by)); + } let users: Vec = database::models::DBUser::get_many_ids(authors, &**pool, &redis) @@ -605,6 +609,7 @@ pub async fn thread_issues_create( project_id, transaction, &redis, + None, ) .await } @@ -684,10 +689,13 @@ pub async fn thread_issue_edit( } if project_exists { + let project_editor = + (edit.user_addressed == Some(false)).then_some(&user); super::projects::mutation::finalize_mutation( project_id, transaction, &redis, + project_editor, ) .await } else { @@ -742,6 +750,7 @@ pub async fn thread_issue_delete( project_id, transaction, &redis, + None, ) .await } else { diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 1473d1ad1f2..0b45b8ee3a1 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -24,6 +24,7 @@ use crate::models::projects::{ }; use crate::models::projects::{DependencyType, skip_nulls}; use crate::models::teams::ProjectPermissions; +use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::search::SearchState; use crate::util::http::HttpClient; @@ -159,15 +160,23 @@ pub async fn version_create( let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); + let user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::VERSION_CREATE, + ) + .await? + .1; + let result = version_create_inner( - req, &mut payload, &mut transaction, &redis, &**file_host, &mut uploaded_files, - &client, - &session_queue, + &user, &http, ) .await; @@ -189,6 +198,7 @@ pub async fn version_create( *project_id, transaction, &redis, + Some(&user), ) .await?; search_state @@ -205,14 +215,12 @@ pub async fn version_create( #[allow(clippy::too_many_arguments)] async fn version_create_inner( - req: HttpRequest, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, redis: &RedisPool, file_host: &dyn FileHost, uploaded_files: &mut Vec, - pool: &PgPool, - session_queue: &AuthQueue, + user: &User, http: &reqwest::Client, ) -> Result<(HttpResponse, models::DBProjectId, models::DBVersionId), CreateError> { @@ -220,16 +228,6 @@ async fn version_create_inner( let mut version_builder = None; let mut selected_loaders = None; - let user = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Scopes::VERSION_CREATE, - ) - .await? - .1; - let mut error = None; while let Some(item) = payload.next().await { let mut field: Field = item?; @@ -647,8 +645,17 @@ pub async fn upload_file_to_version( let version_id = url_data.into_inner().0; let db_version_id = models::DBVersionId::from(version_id); + let user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::VERSION_WRITE, + ) + .await? + .1; + let result = upload_file_to_version_inner( - req, &mut payload, client.clone(), &mut transaction, @@ -656,7 +663,7 @@ pub async fn upload_file_to_version( &**file_host, &mut uploaded_files, db_version_id, - &session_queue, + &user, &http, ) .await; @@ -678,6 +685,7 @@ pub async fn upload_file_to_version( *project_id, transaction, &redis, + Some(&user), ) .await?; search_state @@ -691,7 +699,6 @@ pub async fn upload_file_to_version( #[allow(clippy::too_many_arguments)] async fn upload_file_to_version_inner( - req: HttpRequest, payload: &mut Multipart, client: Data, transaction: &mut PgTransaction<'_>, @@ -699,22 +706,12 @@ async fn upload_file_to_version_inner( file_host: &dyn FileHost, uploaded_files: &mut Vec, version_id: models::DBVersionId, - session_queue: &AuthQueue, + user: &User, http: &reqwest::Client, ) -> Result<(HttpResponse, models::DBProjectId), CreateError> { let mut initial_file_data: Option = None; let mut file_builders: Vec = Vec::new(); - let user = get_user_from_headers( - &req, - &**client, - &redis, - session_queue, - Scopes::VERSION_WRITE, - ) - .await? - .1; - let result = models::DBVersion::get(version_id, &**client, &redis).await?; let Some(version) = result else { diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 7550d225510..a6a850a4ee4 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -974,6 +974,7 @@ pub async fn delete_file( row.project_id, transaction, &redis, + Some(&user), ) .await?; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 52fe770f3ff..d8539aeb3a4 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -922,6 +922,7 @@ pub async fn version_edit_helper( version_item.inner.project_id, transaction, &redis, + Some(&user), ) .await?; database::models::DBVersion::clear_cache(&version_item, &redis) @@ -1315,6 +1316,7 @@ pub async fn version_delete( version.inner.project_id, transaction, &redis, + Some(&user), ) .await?; search_state diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index ee1e98419c4..efe8c1dbc9b 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -16,6 +16,7 @@ pub mod img; pub mod ip; pub mod kafka; pub mod neverbounce; +pub mod non_empty_vec; pub mod ratelimit; pub mod routes; pub mod sentry; diff --git a/apps/labrinth/src/util/non_empty_vec.rs b/apps/labrinth/src/util/non_empty_vec.rs new file mode 100644 index 00000000000..4fd55931891 --- /dev/null +++ b/apps/labrinth/src/util/non_empty_vec.rs @@ -0,0 +1,85 @@ +use derive_more::{Display, Error}; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; +use std::ops::Deref; + +#[derive(Debug, Display, Error)] +#[display("must have at least one item")] +pub struct Empty; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct NonEmptyVec(Vec); + +impl Deref for NonEmptyVec { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl IntoIterator for NonEmptyVec { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl TryFrom> for NonEmptyVec { + type Error = Empty; + + fn try_from(value: Vec) -> Result { + if value.is_empty() { + Err(Empty) + } else { + Ok(Self(value)) + } + } +} + +impl<'de, T> Deserialize<'de> for NonEmptyVec +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Vec::deserialize(deserializer) + .and_then(|value| Self::try_from(value).map_err(D::Error::custom)) + } +} + +impl utoipa::__dev::ComposeSchema for NonEmptyVec +where + T: utoipa::__dev::ComposeSchema, +{ + fn compose( + schemas: Vec>, + ) -> utoipa::openapi::RefOr { + let item = schemas + .first() + .cloned() + .unwrap_or_else(|| T::compose(schemas)); + utoipa::openapi::ArrayBuilder::new() + .items(item) + .min_items(Some(1)) + .into() + } +} + +impl utoipa::ToSchema for NonEmptyVec +where + T: utoipa::ToSchema + utoipa::__dev::ComposeSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} From 02d1c38518461eb2bfea0126e1a4f714a614b30e Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:15:50 +0100 Subject: [PATCH 11/25] fix migration --- ...6120000_thread_issues.sql => 20260916120001_thread_issues.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/labrinth/migrations/{20260916120000_thread_issues.sql => 20260916120001_thread_issues.sql} (100%) diff --git a/apps/labrinth/migrations/20260916120000_thread_issues.sql b/apps/labrinth/migrations/20260916120001_thread_issues.sql similarity index 100% rename from apps/labrinth/migrations/20260916120000_thread_issues.sql rename to apps/labrinth/migrations/20260916120001_thread_issues.sql From a23570d4f0bfff79bf149df7cfa647b2572c7423 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:36:25 +0100 Subject: [PATCH 12/25] moderators can modify user_addressed --- apps/labrinth/src/routes/v3/threads.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 14e775a52cd..7a4995bfad9 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -662,11 +662,12 @@ pub async fn thread_issue_edit( prepare_thread_issue_mutation(project_id, &mut transaction).await?; } if edit.user_addressed.is_some() + && !user.role.is_mod() && !is_project_team_member(project_id, user.id.into(), &mut transaction) .await? { return Err(ApiError::Auth(eyre::eyre!( - "only project team members can address thread issues" + "only project team members or moderators can address thread issues" ))); } let updated = database::models::DBThreadIssue::update_flags( From c683b32a794d9038beded2f437afdca35e82d270 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:47:08 +0100 Subject: [PATCH 13/25] don't allow users to address an issue if they haven't fixed it --- .../src/routes/v3/projects/mutation.rs | 40 +++++++++++++++++-- apps/labrinth/src/routes/v3/threads.rs | 16 ++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index d35a0def8ad..1f2192a9cbb 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -3,13 +3,15 @@ use xredis::RedisPool; use crate::database::models::project_item::ProjectQueryResult; use crate::database::models::{ - DBProjectId, DBTeamId, DBThreadIssue, DBUserId, DBVersionId, + DBProjectId, DBTeamId, DBThreadIssue, DBThreadIssueId, DBUserId, + DBVersionId, }; use crate::database::{PgTransaction, models as db_models}; use crate::models::ids::ProjectId; use crate::models::projects::{Project, ProjectStatus, Version}; use crate::models::thread_issues::{ - ThreadIssueContext, ThreadIssueTeamMember, ThreadIssueVerdict, + ThreadIssueContext, ThreadIssueTarget, ThreadIssueTeamMember, + ThreadIssueValueState, ThreadIssueVerdict, }; use crate::models::users::User; use crate::routes::ApiError; @@ -19,14 +21,41 @@ use crate::validate::project::{ ProjectNagSeverity, validate_with_context as validate_project, }; -struct SyncedProjectState { +pub(crate) struct SyncedProjectState { data: ProjectQueryResult, project: Project, + versions: Vec, + disclosures: Vec, + team_members: Vec, thread_issues: Vec, has_required_nags: bool, } -async fn sync_project_state( +impl SyncedProjectState { + pub(crate) fn can_address_issue( + &self, + issue_id: DBThreadIssueId, + ) -> Option { + let context = ThreadIssueContext { + project: &self.project, + versions: &self.versions, + disclosures: &self.disclosures, + team_members: &self.team_members, + }; + self.thread_issues + .iter() + .find(|issue| issue.id == issue_id) + .map(|issue| { + issue.facets.iter().all(|facet| { + matches!(&facet.what, ThreadIssueTarget::Acknowledge { .. }) + || facet.what.value_state(&context, false) + != ThreadIssueValueState::SameAsOriginal + }) + }) + } +} + +pub(crate) async fn sync_project_state( project_id: DBProjectId, transaction: &mut PgTransaction<'_>, redis: &RedisPool, @@ -136,6 +165,9 @@ async fn sync_project_state( Ok(SyncedProjectState { data, project, + versions, + disclosures, + team_members, thread_issues, has_required_nags, }) diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 7a4995bfad9..30e2360bb06 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -670,6 +670,22 @@ pub async fn thread_issue_edit( "only project team members or moderators can address thread issues" ))); } + if edit.user_addressed == Some(true) && !user.role.is_mod() { + let state = super::projects::mutation::sync_project_state( + project_id, + &mut transaction, + &redis, + ) + .await?; + if !state + .can_address_issue(issue_id) + .wrap_not_found_err("resource not found")? + { + return Err(ApiError::Request(eyre::eyre!( + "moderation issue requirements must be changed before addressing the issue" + ))); + } + } let updated = database::models::DBThreadIssue::update_flags( issue_id, edit.user_addressed, From a4683f2b006798009a628bad912fea24c6fa0df8 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:20:12 +0100 Subject: [PATCH 14/25] if issue state changes back to open, reset its user_addressed --- .../src/database/models/thread_issue_item.rs | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs index c824d51ec6a..a309c0a921b 100644 --- a/apps/labrinth/src/database/models/thread_issue_item.rs +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr, eyre}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; +use std::collections::HashSet; pub struct ThreadIssueBuilder { pub facets: Vec, @@ -277,16 +278,38 @@ impl DBThreadIssue { "locking project thread issues for verdict synchronization", )?; + let reset_issue_ids = rows + .iter() + .filter(|row| { + row.user_addressed + && row.what.0.verdict( + context, + row.user_addressed, + row.moderator_verified, + ) == ThreadIssueVerdict::Open + }) + .map(|row| row.id) + .collect::>(); + for issue_id in &reset_issue_ids { + Self::update_flags( + DBThreadIssueId(*issue_id), + Some(false), + None, + transaction, + ) + .await + .wrap_err("resetting open thread issue addressed state")?; + } + let mut issues = Vec::::new(); for row in rows { let stored_verdict = parse_verdict(row.id, row.facet_id, &row.verdict)?; let what = row.what.0; - let verdict = what.verdict( - context, - row.user_addressed, - row.moderator_verified, - ); + let user_addressed = + row.user_addressed && !reset_issue_ids.contains(&row.id); + let verdict = + what.verdict(context, user_addressed, row.moderator_verified); if verdict != stored_verdict { sqlx::query!( @@ -309,7 +332,7 @@ impl DBThreadIssue { thread_id: DBThreadId(row.thread_id), created_by: DBUserId(row.created_by), why: row.why, - user_addressed: row.user_addressed, + user_addressed, moderator_verified: row.moderator_verified, facets: Vec::new(), verdict: ThreadIssueVerdict::Open, From a09d5f412e843f3d3b2b0bb69cd21880c7a2a4c5 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:15:47 +0100 Subject: [PATCH 15/25] first batch of changes --- ...c2145a8fb9183e4e3b6d82f705468e50b8214.json | 17 ++++ ...75fc5971fd4550250196f3d8ee888a33b6dd2.json | 22 +++++ ...2b04390a76bac652c64e225565e2a4923e728.json | 16 ++++ ...4d290979589d5404e10b0f0fa4c6a60c4fba5.json | 76 ++++++++++++++++ ...ed932e80ff2262fa1eb5fdd1511aae3774808.json | 76 ++++++++++++++++ ...2bbcbe050a65128b3c89346510bde79d43487.json | 16 ++++ ...0261007120000_thread_issue_facet_flags.sql | 14 +++ .../src/database/models/thread_issue_item.rs | 89 ++++++------------ apps/labrinth/src/models/v3/thread_issues.rs | 90 +++++++++++-------- apps/labrinth/src/routes/v3/mod.rs | 2 +- .../src/routes/v3/projects/mutation.rs | 26 +++--- apps/labrinth/src/routes/v3/threads.rs | 49 +++++++--- 12 files changed, 364 insertions(+), 129 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json create mode 100644 apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json create mode 100644 apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json create mode 100644 apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json create mode 100644 apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json create mode 100644 apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json create mode 100644 apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql diff --git a/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json b/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json new file mode 100644 index 00000000000..284f004cbaa --- /dev/null +++ b/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n why\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214" +} diff --git a/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json b/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json new file mode 100644 index 00000000000..91d6cac8f21 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT thread.mod_id\n\t\tFROM threads_issue_facets facet\n\t\tINNER JOIN threads_issues issue ON issue.id = facet.issue_id\n\t\tINNER JOIN threads thread ON thread.id = issue.thread_id\n\t\tWHERE facet.id = $1\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2" +} diff --git a/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json b/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json new file mode 100644 index 00000000000..2300e39b279 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET verdict = $2, user_addressed = $3\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728" +} diff --git a/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json b/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json new file mode 100644 index 00000000000..5c4589e1f2d --- /dev/null +++ b/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n facet.user_addressed,\n facet.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = ANY($1)\n ORDER BY issue.created_at, issue.id, facet.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "facet_id!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5" +} diff --git a/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json b/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json new file mode 100644 index 00000000000..c51de67d41e --- /dev/null +++ b/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n facet.user_addressed,\n facet.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = $1\n ORDER BY issue.id, facet.id\n FOR UPDATE OF issue, facet\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "facet_id!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808" +} diff --git a/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json b/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json new file mode 100644 index 00000000000..55ea586f3fa --- /dev/null +++ b/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified),\n verdict = CASE WHEN $3 = TRUE THEN 'resolved' ELSE verdict END\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487" +} diff --git a/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql b/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql new file mode 100644 index 00000000000..c64c8d8398a --- /dev/null +++ b/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql @@ -0,0 +1,14 @@ +ALTER TABLE threads_issue_facets + ADD COLUMN user_addressed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN moderator_verified BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE threads_issue_facets facet +SET + user_addressed = issue.user_addressed, + moderator_verified = issue.moderator_verified +FROM threads_issues issue +WHERE facet.issue_id = issue.id; + +ALTER TABLE threads_issues + DROP COLUMN user_addressed, + DROP COLUMN moderator_verified; diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs index a309c0a921b..0bcd8ae8e85 100644 --- a/apps/labrinth/src/database/models/thread_issue_item.rs +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -10,7 +10,6 @@ use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr, eyre}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; -use std::collections::HashSet; pub struct ThreadIssueBuilder { pub facets: Vec, @@ -23,8 +22,6 @@ pub struct DBThreadIssue { pub thread_id: DBThreadId, pub created_by: DBUserId, pub why: serde_json::Value, - pub user_addressed: bool, - pub moderator_verified: bool, pub facets: Vec, pub verdict: ThreadIssueVerdict, pub created_at: DateTime, @@ -50,8 +47,8 @@ impl DBThreadIssue { issue.thread_id, issue.created_by, issue.why, - issue.user_addressed, - issue.moderator_verified, + facet.user_addressed, + facet.moderator_verified, issue.created_at, facet.id AS "facet_id!", facet.what AS "what: Json", @@ -80,8 +77,6 @@ impl DBThreadIssue { thread_id: DBThreadId(row.thread_id), created_by: DBUserId(row.created_by), why: row.why, - user_addressed: row.user_addressed, - moderator_verified: row.moderator_verified, facets: Vec::new(), verdict: ThreadIssueVerdict::Open, created_at: row.created_at, @@ -95,6 +90,8 @@ impl DBThreadIssue { .push(ThreadIssueFacet { id: DBThreadIssueFacetId(row.facet_id).into(), what, + user_addressed: row.user_addressed, + moderator_verified: row.moderator_verified, verdict: facet_verdict, }); } @@ -128,11 +125,9 @@ impl DBThreadIssue { id, thread_id, created_by, - why, - user_addressed, - moderator_verified + why ) - VALUES ($1, $2, $3, $4, FALSE, FALSE) + VALUES ($1, $2, $3, $4) "#, id as DBThreadIssueId, thread_id as DBThreadId, @@ -191,42 +186,28 @@ impl DBThreadIssue { Ok(ids) } - pub async fn update_flags( - id: DBThreadIssueId, + pub async fn update_facet_flags( + id: DBThreadIssueFacetId, user_addressed: Option, moderator_verified: Option, transaction: &mut PgTransaction<'_>, ) -> Result { let result = sqlx::query!( r#" - UPDATE threads_issues + UPDATE threads_issue_facets SET user_addressed = COALESCE($2, user_addressed), - moderator_verified = COALESCE($3, moderator_verified) + moderator_verified = COALESCE($3, moderator_verified), + verdict = CASE WHEN $3 = TRUE THEN 'resolved' ELSE verdict END WHERE id = $1 "#, - id as DBThreadIssueId, + id as DBThreadIssueFacetId, user_addressed, moderator_verified, ) .execute(&mut *transaction) .await - .wrap_err("updating thread issue")?; - - if result.rows_affected() > 0 && moderator_verified == Some(true) { - sqlx::query!( - r#" - UPDATE threads_issue_facets - SET verdict = $2 - WHERE issue_id = $1 - "#, - id as DBThreadIssueId, - ThreadIssueVerdict::Resolved.as_str(), - ) - .execute(&mut *transaction) - .await - .wrap_err("resolving verified thread issue facets")?; - } + .wrap_err("updating thread issue facet")?; Ok(result.rows_affected() > 0) } @@ -257,8 +238,8 @@ impl DBThreadIssue { issue.thread_id, issue.created_by, issue.why, - issue.user_addressed, - issue.moderator_verified, + facet.user_addressed, + facet.moderator_verified, issue.created_at, facet.id AS "facet_id!", facet.what AS "what: Json", @@ -278,48 +259,30 @@ impl DBThreadIssue { "locking project thread issues for verdict synchronization", )?; - let reset_issue_ids = rows - .iter() - .filter(|row| { - row.user_addressed - && row.what.0.verdict( - context, - row.user_addressed, - row.moderator_verified, - ) == ThreadIssueVerdict::Open - }) - .map(|row| row.id) - .collect::>(); - for issue_id in &reset_issue_ids { - Self::update_flags( - DBThreadIssueId(*issue_id), - Some(false), - None, - transaction, - ) - .await - .wrap_err("resetting open thread issue addressed state")?; - } - let mut issues = Vec::::new(); for row in rows { let stored_verdict = parse_verdict(row.id, row.facet_id, &row.verdict)?; let what = row.what.0; - let user_addressed = - row.user_addressed && !reset_issue_ids.contains(&row.id); + let mut user_addressed = row.user_addressed; let verdict = what.verdict(context, user_addressed, row.moderator_verified); - if verdict != stored_verdict { + if verdict == ThreadIssueVerdict::Open { + user_addressed = false; + } + + if verdict != stored_verdict || user_addressed != row.user_addressed + { sqlx::query!( r#" UPDATE threads_issue_facets - SET verdict = $2 + SET verdict = $2, user_addressed = $3 WHERE id = $1 "#, row.facet_id, verdict.as_str(), + user_addressed, ) .execute(&mut *transaction) .await @@ -332,8 +295,6 @@ impl DBThreadIssue { thread_id: DBThreadId(row.thread_id), created_by: DBUserId(row.created_by), why: row.why, - user_addressed, - moderator_verified: row.moderator_verified, facets: Vec::new(), verdict: ThreadIssueVerdict::Open, created_at: row.created_at, @@ -347,6 +308,8 @@ impl DBThreadIssue { .push(ThreadIssueFacet { id: DBThreadIssueFacetId(row.facet_id).into(), what, + user_addressed, + moderator_verified: row.moderator_verified, verdict, }); } diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index b7039717f7e..68ffa770d31 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -31,7 +31,7 @@ pub struct ThreadIssueTeamMember { /// Issue that a moderator has flagged on a project in its moderation thread. /// /// An issue groups related actionable facets under one reason. The creator -/// addresses the issue as a whole, and a moderator verifies it as a whole. +/// addresses each facet independently, and a moderator verifies each facet. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ThreadIssue { pub id: ThreadIssueId, @@ -42,15 +42,6 @@ pub struct ThreadIssue { /// This is treated as an opaque JSON blob by the backend; it is up to the /// frontend to define a schema for it, and to render/localize it properly. pub why: serde_json::Value, - /// Has the user confirmed that they've seen this issue and (attempted to) - /// resolve it? - pub user_addressed: bool, - /// Has a moderator seen the user's resolution and explicitly marked it as - /// resolved? - /// - /// If a moderator marks this issue as verified, then it will be locked to - /// [`ThreadIssueVerdict::Resolved`]. - pub moderator_verified: bool, /// The immutable project parts that must be changed to resolve this issue. pub facets: Vec, /// Verdict derived from all facet verdicts. @@ -64,7 +55,16 @@ pub struct ThreadIssueFacet { pub id: ThreadIssueFacetId, /// What part of a project this facet applies to. pub what: ThreadIssueTarget, - /// Verdict derived from the current project state and issue-level flags. + /// Has the user confirmed that they've seen this facet and (attempted to) + /// resolve it? + pub user_addressed: bool, + /// Has a moderator seen the user's resolution and explicitly marked it as + /// resolved? + /// + /// If a moderator marks this facet as verified, then it will be locked to + /// [`ThreadIssueVerdict::Resolved`]. + pub moderator_verified: bool, + /// Verdict derived from the current project state and facet-level flags. pub verdict: ThreadIssueVerdict, } @@ -340,19 +340,14 @@ impl ThreadIssueTarget { } } Self::RemoveTags { tags } => { - let remaining = tags - .iter() - .filter(|tag| { - project.categories.contains(tag) - || project.additional_categories.contains(tag) - }) - .count(); - if remaining == 0 { - ThreadIssueValueState::SameAsSuggested - } else if remaining == tags.len() { + let remaining = tags.iter().any(|tag| { + project.categories.contains(tag) + || project.additional_categories.contains(tag) + }); + if remaining { ThreadIssueValueState::SameAsOriginal } else { - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsSuggested } } Self::ModifyLinks { links } => { @@ -757,8 +752,6 @@ impl ThreadIssue { id: data.id.into(), created_by: user.role.is_mod().then(|| data.created_by.into()), why: data.why, - user_addressed: data.user_addressed, - moderator_verified: data.moderator_verified, facets: data.facets, verdict: data.verdict, created_at: data.created_at, @@ -1061,7 +1054,11 @@ mod tests { ); assert_eq!( tags.value_state(&context(&project), false), - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + tags.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open ); project.categories.clear(); @@ -1069,6 +1066,10 @@ mod tests { tags.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); + assert_eq!( + tags.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); } #[test] @@ -1298,16 +1299,27 @@ mod tests { #[test] fn issue_verdict_aggregates_facet_verdicts() { - let facets = |verdicts: &[ThreadIssueVerdict]| { - verdicts + let project = project(); + let facets = |flags: &[(bool, bool)]| { + flags .iter() .enumerate() - .map(|(id, verdict)| ThreadIssueFacet { - id: ThreadIssueFacetId(id as u64), - what: ThreadIssueTarget::Acknowledge { - mode: ThreadIssueAcknowledgement::Checkbox, - }, - verdict: *verdict, + .map(|(id, &(user_addressed, moderator_verified))| { + let what = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + let verdict = what.verdict( + &context(&project), + user_addressed, + moderator_verified, + ); + ThreadIssueFacet { + id: ThreadIssueFacetId(id as u64), + what, + user_addressed, + moderator_verified, + verdict, + } }) .collect::>() }; @@ -1318,22 +1330,22 @@ mod tests { ); assert_eq!( ThreadIssueVerdict::from_facets(&facets(&[ - ThreadIssueVerdict::Resolved, - ThreadIssueVerdict::Resolved, + (false, true), + (true, true), ])), ThreadIssueVerdict::Resolved ); assert_eq!( ThreadIssueVerdict::from_facets(&facets(&[ - ThreadIssueVerdict::Addressed, - ThreadIssueVerdict::Resolved, + (true, false), + (false, true), ])), ThreadIssueVerdict::Addressed ); assert_eq!( ThreadIssueVerdict::from_facets(&facets(&[ - ThreadIssueVerdict::Open, - ThreadIssueVerdict::Resolved, + (false, false), + (false, true), ])), ThreadIssueVerdict::Open ); diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index d9528aee098..6e90c004510 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -196,7 +196,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { threads::threads_get_route, threads::thread_send_message_route, threads::thread_issues_create, - threads::thread_issue_edit, + threads::thread_issue_facet_edit, threads::thread_issue_delete, threads::message_delete_route, users::all_projects, diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 1f2192a9cbb..58dae8ca44b 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -3,7 +3,7 @@ use xredis::RedisPool; use crate::database::models::project_item::ProjectQueryResult; use crate::database::models::{ - DBProjectId, DBTeamId, DBThreadIssue, DBThreadIssueId, DBUserId, + DBProjectId, DBTeamId, DBThreadIssue, DBThreadIssueFacetId, DBUserId, DBVersionId, }; use crate::database::{PgTransaction, models as db_models}; @@ -32,9 +32,9 @@ pub(crate) struct SyncedProjectState { } impl SyncedProjectState { - pub(crate) fn can_address_issue( + pub(crate) fn can_address_facet( &self, - issue_id: DBThreadIssueId, + facet_id: DBThreadIssueFacetId, ) -> Option { let context = ThreadIssueContext { project: &self.project, @@ -44,13 +44,12 @@ impl SyncedProjectState { }; self.thread_issues .iter() - .find(|issue| issue.id == issue_id) - .map(|issue| { - issue.facets.iter().all(|facet| { - matches!(&facet.what, ThreadIssueTarget::Acknowledge { .. }) - || facet.what.value_state(&context, false) - != ThreadIssueValueState::SameAsOriginal - }) + .flat_map(|issue| &issue.facets) + .find(|facet| facet.id == facet_id.into()) + .map(|facet| { + matches!(&facet.what, ThreadIssueTarget::Acknowledge { .. }) + || facet.what.value_state(&context, false) + != ThreadIssueValueState::SameAsOriginal }) } } @@ -291,9 +290,10 @@ pub(crate) async fn finalize_project_edit( ))); } - for issue in &state.thread_issues { - DBThreadIssue::update_flags( - issue.id, + for facet in state.thread_issues.iter().flat_map(|issue| &issue.facets) + { + DBThreadIssue::update_facet_flags( + facet.id.into(), None, Some(true), &mut transaction, diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 30e2360bb06..d6c7cea2459 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -7,7 +7,9 @@ use crate::database::models::thread_issue_item::ThreadIssueBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models::ids::{ThreadId, ThreadIssueId, ThreadMessageId}; +use crate::models::ids::{ + ThreadId, ThreadIssueFacetId, ThreadIssueId, ThreadMessageId, +}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; @@ -30,7 +32,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(thread_get_route) .service(thread_send_message_route) .service(thread_issues_create) - .service(thread_issue_edit) + .service(thread_issue_facet_edit) .service(thread_issue_delete) .service(message_delete_route) .service(threads_get_route); @@ -424,7 +426,7 @@ pub struct NewThreadIssues { } #[derive(Deserialize, utoipa::ToSchema)] -pub struct EditThreadIssue { +pub struct EditThreadIssueFacet { pub user_addressed: Option, pub moderator_verified: Option, } @@ -464,6 +466,27 @@ async fn thread_issue_project_id( .map(database::models::DBProjectId)) } +async fn thread_issue_facet_project_id( + facet_id: database::models::DBThreadIssueFacetId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query_scalar!( + r#" + SELECT thread.mod_id + FROM threads_issue_facets facet + INNER JOIN threads_issues issue ON issue.id = facet.issue_id + INNER JOIN threads thread ON thread.id = issue.thread_id + WHERE facet.id = $1 + "#, + facet_id as database::models::DBThreadIssueFacetId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread issue project")? + .flatten() + .map(database::models::DBProjectId)) +} + async fn project_exists( project_id: database::models::DBProjectId, pool: &PgPool, @@ -609,17 +632,17 @@ pub async fn thread_issues_create( #[utoipa::path( tag = "threads", - request_body = EditThreadIssue, + request_body = EditThreadIssueFacet, responses((status = NO_CONTENT)) )] -#[patch("/thread/issue/{id}")] -pub async fn thread_issue_edit( +#[patch("/thread/issue/facet/{id}")] +pub async fn thread_issue_facet_edit( req: HttpRequest, - info: web::Path<(ThreadIssueId,)>, + info: web::Path<(ThreadIssueFacetId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, - web::Json(edit): web::Json, + web::Json(edit): web::Json, ) -> Result<(), ApiError> { if edit.user_addressed.is_none() && edit.moderator_verified.is_none() { return Err(ApiError::Request(eyre::eyre!( @@ -642,9 +665,9 @@ pub async fn thread_issue_edit( .await .wrap_auth_err("authenticating API request")? .1; - let issue_id: database::models::DBThreadIssueId = + let facet_id: database::models::DBThreadIssueFacetId = info.into_inner().0.into(); - let project_id = thread_issue_project_id(issue_id, &pool) + let project_id = thread_issue_facet_project_id(facet_id, &pool) .await? .wrap_not_found_err("resource not found")?; let project_exists = project_exists(project_id, &pool).await?; @@ -678,7 +701,7 @@ pub async fn thread_issue_edit( ) .await?; if !state - .can_address_issue(issue_id) + .can_address_facet(facet_id) .wrap_not_found_err("resource not found")? { return Err(ApiError::Request(eyre::eyre!( @@ -686,8 +709,8 @@ pub async fn thread_issue_edit( ))); } } - let updated = database::models::DBThreadIssue::update_flags( - issue_id, + let updated = database::models::DBThreadIssue::update_facet_flags( + facet_id, edit.user_addressed, edit.moderator_verified, &mut transaction, From 9d2223975ea02d3d809a326a851db2868c97bad0 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:25:02 +0100 Subject: [PATCH 16/25] auto approve when issues resolved --- ...5cfe1c7f370a7de5d5d731201612e38fab606.json | 16 ++++ apps/labrinth/src/models/v2/threads.rs | 4 + apps/labrinth/src/models/v3/threads.rs | 5 ++ apps/labrinth/src/routes/v3/projects/mod.rs | 90 ++++++++++++------- .../src/routes/v3/projects/mutation.rs | 23 +++++ 5 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json diff --git a/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json b/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json new file mode 100644 index 00000000000..763fce19387 --- /dev/null +++ b/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = $1,\n approved = CASE WHEN $3 THEN COALESCE(approved, NOW()) ELSE approved END\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606" +} diff --git a/apps/labrinth/src/models/v2/threads.rs b/apps/labrinth/src/models/v2/threads.rs index 14153d6024c..efd87f6cf2c 100644 --- a/apps/labrinth/src/models/v2/threads.rs +++ b/apps/labrinth/src/models/v2/threads.rs @@ -92,6 +92,10 @@ impl From for LegacyMessageBody { crate::models::v3::threads::MessageBody::StatusChange { new_status, old_status, + } + | crate::models::v3::threads::MessageBody::AutoApproval { + new_status, + old_status, } => LegacyMessageBody::StatusChange { new_status, old_status, diff --git a/apps/labrinth/src/models/v3/threads.rs b/apps/labrinth/src/models/v3/threads.rs index e6a23f31fb3..5bef100510c 100644 --- a/apps/labrinth/src/models/v3/threads.rs +++ b/apps/labrinth/src/models/v3/threads.rs @@ -48,6 +48,10 @@ pub enum MessageBody { new_status: ProjectStatus, old_status: ProjectStatus, }, + AutoApproval { + new_status: ProjectStatus, + old_status: ProjectStatus, + }, TechReview { verdict: DelphiVerdict, }, @@ -71,6 +75,7 @@ impl MessageBody { | Self::TechReviewExited | Self::TechReviewExitFileDeleted => true, Self::StatusChange { .. } + | Self::AutoApproval { .. } | Self::ThreadClosure | Self::ThreadReopen => false, } diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 51ab454dac6..a6930309645 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1405,7 +1405,7 @@ pub async fn project_edit_internal( .wrap_api_err("deleting unused images")?; if submit_for_review { - submit_project_for_review( + reindex_versions |= submit_project_for_review( &reloaded_project, &user, team_member.as_ref().is_none_or(|member| !member.accepted), @@ -1453,7 +1453,7 @@ async fn submit_project_for_review( sync_archival_disclosure: bool, transaction: &mut PgTransaction<'_>, redis: &RedisPool, -) -> Result<(), ApiError> { +) -> Result { let archival_disclosure = db_models::DBProjectDisclosure::get_many_for_project( project.inner.id, @@ -1467,6 +1467,32 @@ async fn submit_project_for_review( matches!(disclosure.disclosure, ProjectDisclosure::Archived { .. }) }); + if sync_archival_disclosure + && archival_disclosure + .is_some_and(|disclosure| disclosure.lock_status.allows_removal()) + { + db_models::DBProjectDisclosure::remove( + project.inner.id, + ProjectDisclosureType::Archived, + user.id.into(), + false, + &mut *transaction, + ) + .await + .wrap_internal_err("failed to remove archival disclosure")?; + } + + let state = + mutation::sync_project_state(project.inner.id, transaction, redis) + .await?; + let auto_approval_status = if user.role.is_mod() { + None + } else { + state.auto_approval_status() + }; + let auto_approved = auto_approval_status.is_some(); + let new_status = auto_approval_status.unwrap_or(ProjectStatus::Processing); + sqlx::query!( " UPDATE mods @@ -1479,7 +1505,7 @@ async fn submit_project_for_review( .await .wrap_internal_err("querying database for `project_edit_internal`")?; - if notify_team_members { + if notify_team_members || auto_approved { let notified_members = sqlx::query!( " SELECT tm.user_id id @@ -1498,7 +1524,7 @@ async fn submit_project_for_review( body: NotificationBody::StatusChange { project_id: project.inner.id.into(), old_status: project.inner.status, - new_status: ProjectStatus::Processing, + new_status, }, } .insert_many(notified_members.clone(), &mut *transaction, redis) @@ -1508,10 +1534,16 @@ async fn submit_project_for_review( )?; NotificationBuilder { - body: NotificationBody::ProjectStatusNeutral { - project_id: project.inner.id.into(), - old_status: project.inner.status, - new_status: ProjectStatus::Processing, + body: if auto_approved { + NotificationBody::ProjectStatusApproved { + project_id: project.inner.id.into(), + } + } else { + NotificationBody::ProjectStatusNeutral { + project_id: project.inner.id.into(), + old_status: project.inner.status, + new_status, + } }, } .insert_many(notified_members, &mut *transaction, redis) @@ -1522,10 +1554,21 @@ async fn submit_project_for_review( } ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: MessageBody::StatusChange { - new_status: ProjectStatus::Processing, - old_status: project.inner.status, + author_id: if auto_approved { + None + } else { + Some(user.id.into()) + }, + body: if auto_approved { + MessageBody::AutoApproval { + old_status: project.inner.status, + new_status, + } + } else { + MessageBody::StatusChange { + new_status, + old_status: project.inner.status, + } }, thread_id: project.thread_id, hide_identity: false, @@ -1539,32 +1582,19 @@ async fn submit_project_for_review( sqlx::query!( " UPDATE mods - SET status = $1 + SET status = $1, + approved = CASE WHEN $3 THEN COALESCE(approved, NOW()) ELSE approved END WHERE (id = $2) ", - ProjectStatus::Processing.as_str(), + new_status.as_str(), project.inner.id as db_ids::DBProjectId, + auto_approved, ) .execute(&mut *transaction) .await .wrap_internal_err("querying database for `project_edit_internal`")?; - if sync_archival_disclosure - && archival_disclosure - .is_some_and(|disclosure| disclosure.lock_status.allows_removal()) - { - db_models::DBProjectDisclosure::remove( - project.inner.id, - ProjectDisclosureType::Archived, - user.id.into(), - false, - &mut *transaction, - ) - .await - .wrap_internal_err("failed to remove archival disclosure")?; - } - - Ok(()) + Ok(auto_approved) } pub async fn edit_project_categories( diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 58dae8ca44b..63eb03b8fce 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -32,6 +32,29 @@ pub(crate) struct SyncedProjectState { } impl SyncedProjectState { + pub(crate) fn auto_approval_status(&self) -> Option { + if !matches!( + self.project.status, + ProjectStatus::Rejected | ProjectStatus::Withheld + ) || self.thread_issues.is_empty() + || self + .thread_issues + .iter() + .any(|issue| issue.verdict != ThreadIssueVerdict::Resolved) + { + return None; + } + + Some( + self.project + .requested_status + .filter(|status| { + status.is_approved() && status.can_be_requested() + }) + .unwrap_or(ProjectStatus::Approved), + ) + } + pub(crate) fn can_address_facet( &self, facet_id: DBThreadIssueFacetId, From 5cd1251901f4ea39647f49621a1baaff4201488e Mon Sep 17 00:00:00 2001 From: tdgao Date: Wed, 7 Oct 2026 18:16:41 -0600 Subject: [PATCH 17/25] add MarkAddressed facet --- apps/labrinth/src/models/v3/thread_issues.rs | 8 ++++++++ apps/labrinth/src/routes/v3/projects/mutation.rs | 9 ++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 68ffa770d31..48552e5f4e5 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -135,6 +135,7 @@ pub enum ThreadIssueTarget { Acknowledge { mode: ThreadIssueAcknowledgement, }, + MarkAddressed, } #[derive( @@ -707,6 +708,13 @@ impl ThreadIssueTarget { |current| value_state(address, current), ) } + Self::MarkAddressed => { + if user_addressed { + ThreadIssueValueState::DifferentToOriginal + } else { + ThreadIssueValueState::SameAsOriginal + } + } Self::Acknowledge { mode } => match (mode, user_addressed) { (_, false) => ThreadIssueValueState::SameAsOriginal, (ThreadIssueAcknowledgement::Checkbox, true) => { diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index 63eb03b8fce..eda954aba20 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -70,9 +70,12 @@ impl SyncedProjectState { .flat_map(|issue| &issue.facets) .find(|facet| facet.id == facet_id.into()) .map(|facet| { - matches!(&facet.what, ThreadIssueTarget::Acknowledge { .. }) - || facet.what.value_state(&context, false) - != ThreadIssueValueState::SameAsOriginal + matches!( + &facet.what, + ThreadIssueTarget::Acknowledge { .. } + | ThreadIssueTarget::MarkAddressed + ) || facet.what.value_state(&context, false) + != ThreadIssueValueState::SameAsOriginal }) } } From 15400a9d65833823029f1c8a1c069d11c8dca77c Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 11:35:49 -0600 Subject: [PATCH 18/25] feat: add AddProjectDisclosure facet --- apps/labrinth/src/models/v3/thread_issues.rs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 48552e5f4e5..73bd0205031 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -103,6 +103,9 @@ pub enum ThreadIssueTarget { RemoveGalleryImages { image_ids: NonEmptyVec, }, + AddProjectDisclosures { + disclosure_types: NonEmptyVec, + }, RemoveProjectDisclosures { disclosure_types: NonEmptyVec, }, @@ -436,6 +439,26 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } + Self::AddProjectDisclosures { disclosure_types } => { + let present = disclosure_types + .iter() + .filter(|target| { + context.disclosures.iter().any(|disclosure| { + disclosure.to_parts().is_ok_and( + |(current_type, _)| current_type == *target, + ) + }) + }) + .count(); + + if present == disclosure_types.len() { + ThreadIssueValueState::SameAsSuggested + } else if present == 0 { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } Self::RemoveProjectDisclosures { disclosure_types } => { let remaining = disclosure_types .iter() From 809363d24b637ec50535cc58c7dc118014d7fe90 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 12:01:14 -0600 Subject: [PATCH 19/25] feat: ensure project member has replied in thread to mark reply acknowledge as addressed --- .../src/database/models/thread_item.rs | 39 +++++++++++++ apps/labrinth/src/routes/v3/threads.rs | 55 ++++++++++++++++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs index 4347c7ae0ea..45f4a1e71dc 100644 --- a/apps/labrinth/src/database/models/thread_item.rs +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -123,6 +123,45 @@ impl ThreadBuilder { } impl DBThread { + /// Whether an accepted project or organization team member has posted a + /// public, nonempty text message in the thread after the given time. + pub async fn has_project_member_reply( + thread_id: DBThreadId, + project_id: DBProjectId, + after: DateTime, + transaction: &mut PgTransaction<'_>, + ) -> Result { + sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM threads_messages message + WHERE message.thread_id = $1 + AND message.created > $3 + AND message.body->>'type' = 'text' + AND COALESCE(message.body->>'private', 'false') = 'false' + AND LENGTH(TRIM(message.body->>'body')) > 0 + AND message.author_id IN ( + SELECT member.user_id + FROM mods project + LEFT JOIN organizations organization + ON organization.id = project.organization_id + INNER JOIN team_members member + ON member.team_id = project.team_id + OR member.team_id = organization.team_id + WHERE project.id = $2 AND member.accepted + ) + ) AS "has_reply!" + "#, + thread_id as DBThreadId, + project_id as DBProjectId, + after, + ) + .fetch_one(&mut *transaction) + .await + .wrap_err("checking whether a project team member has replied") + } + pub async fn get<'a, E>(id: DBThreadId, exec: E) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index d6c7cea2459..80ca33e9772 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -14,7 +14,9 @@ use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::ProjectStatus; -use crate::models::thread_issues::ThreadIssueTarget; +use crate::models::thread_issues::{ + ThreadIssueAcknowledgement, ThreadIssueTarget, +}; use crate::models::threads::{MessageBody, Thread, ThreadType}; use crate::models::users::User; use crate::queue::session::AuthQueue; @@ -466,13 +468,21 @@ async fn thread_issue_project_id( .map(database::models::DBProjectId)) } -async fn thread_issue_facet_project_id( +struct ThreadIssueFacetContext { + project_id: database::models::DBProjectId, + thread_id: database::models::DBThreadId, + created_at: chrono::DateTime, + what: ThreadIssueTarget, +} + +async fn thread_issue_facet_context( facet_id: database::models::DBThreadIssueFacetId, pool: &PgPool, -) -> Result, ApiError> { - Ok(sqlx::query_scalar!( +) -> Result, ApiError> { + Ok(sqlx::query!( r#" - SELECT thread.mod_id + SELECT thread.mod_id, issue.thread_id, issue.created_at, + facet.what AS "what: sqlx::types::Json" FROM threads_issue_facets facet INNER JOIN threads_issues issue ON issue.id = facet.issue_id INNER JOIN threads thread ON thread.id = issue.thread_id @@ -482,9 +492,15 @@ async fn thread_issue_facet_project_id( ) .fetch_optional(pool) .await - .wrap_internal_err("fetching thread issue project")? - .flatten() - .map(database::models::DBProjectId)) + .wrap_internal_err("fetching thread issue facet context")? + .and_then(|row| { + row.mod_id.map(|project_id| ThreadIssueFacetContext { + project_id: database::models::DBProjectId(project_id), + thread_id: database::models::DBThreadId(row.thread_id), + created_at: row.created_at, + what: row.what.0, + }) + })) } async fn project_exists( @@ -667,9 +683,10 @@ pub async fn thread_issue_facet_edit( .1; let facet_id: database::models::DBThreadIssueFacetId = info.into_inner().0.into(); - let project_id = thread_issue_facet_project_id(facet_id, &pool) + let facet_context = thread_issue_facet_context(facet_id, &pool) .await? .wrap_not_found_err("resource not found")?; + let project_id = facet_context.project_id; let project_exists = project_exists(project_id, &pool).await?; if edit.moderator_verified.is_some() && !user.role.is_mod() { @@ -693,6 +710,26 @@ pub async fn thread_issue_facet_edit( "only project team members or moderators can address thread issues" ))); } + if edit.user_addressed == Some(true) + && matches!( + facet_context.what, + ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply + } + ) + && !database::models::DBThread::has_project_member_reply( + facet_context.thread_id, + project_id, + facet_context.created_at, + &mut transaction, + ) + .await + .wrap_internal_err("checking project team reply requirement")? + { + return Err(ApiError::Request(eyre::eyre!( + "a project team member must send a message in the thread before addressing this issue" + ))); + } if edit.user_addressed == Some(true) && !user.role.is_mod() { let state = super::projects::mutation::sync_project_state( project_id, From f29a93eb6c0b2de195df021c1033a84e12f29760 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 12:02:40 -0600 Subject: [PATCH 20/25] feat: allow mod/admin to bypass no open issues restriction in submit for review --- apps/labrinth/src/routes/v3/projects/mutation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index eda954aba20..d44eb69575e 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -295,6 +295,7 @@ pub(crate) async fn finalize_project_edit( } if validate_review_state + && !project_editor.is_some_and(|user| user.role.is_mod()) && state .thread_issues .iter() From 514ebd662dc223c280589bd9a6a7752b75615cc1 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 12:04:20 -0600 Subject: [PATCH 21/25] fmt --- apps/labrinth/src/routes/v3/projects/mutation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs index d44eb69575e..d2678fb71b9 100644 --- a/apps/labrinth/src/routes/v3/projects/mutation.rs +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -295,7 +295,7 @@ pub(crate) async fn finalize_project_edit( } if validate_review_state - && !project_editor.is_some_and(|user| user.role.is_mod()) + && !project_editor.is_some_and(|user| user.role.is_mod()) && state .thread_issues .iter() From e8e0e8bf26c4b7f5dd065d9ece5f9046a60efe3d Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 13:31:57 -0600 Subject: [PATCH 22/25] feat: add RemoveIcon facet and for all Remove facets, dont allow partial removal to mark as addressed --- apps/labrinth/src/models/v3/thread_issues.rs | 168 ++++++++++++++----- 1 file changed, 124 insertions(+), 44 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 73bd0205031..3dbe2537901 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -85,6 +85,7 @@ pub enum ThreadIssueTarget { ModifyIcon { original_url: Option, }, + RemoveIcon, RemoveTags { tags: NonEmptyVec, }, @@ -343,6 +344,13 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } + Self::RemoveIcon => { + if project.icon_url.is_none() { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::SameAsOriginal + } + } Self::RemoveTags { tags } => { let remaining = tags.iter().any(|tag| { project.categories.contains(tag) @@ -421,22 +429,17 @@ impl ThreadIssueTarget { } } Self::RemoveGalleryImages { image_ids } => { - let remaining = image_ids - .iter() - .filter(|id| { - project - .gallery - .iter() - .any(|image| image.id.as_ref() == Some(id)) - }) - .count(); + let remaining = image_ids.iter().any(|id| { + project + .gallery + .iter() + .any(|image| image.id.as_ref() == Some(id)) + }); - if remaining == 0 { - ThreadIssueValueState::SameAsSuggested - } else if remaining == image_ids.len() { + if remaining { ThreadIssueValueState::SameAsOriginal } else { - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsSuggested } } Self::AddProjectDisclosures { disclosure_types } => { @@ -460,23 +463,18 @@ impl ThreadIssueTarget { } } Self::RemoveProjectDisclosures { disclosure_types } => { - let remaining = disclosure_types - .iter() - .filter(|target| { - context.disclosures.iter().any(|disclosure| { - disclosure.to_parts().is_ok_and( - |(current_type, _)| current_type == *target, - ) + let remaining = disclosure_types.iter().any(|target| { + context.disclosures.iter().any(|disclosure| { + disclosure.to_parts().is_ok_and(|(current_type, _)| { + current_type == target }) }) - .count(); + }); - if remaining == 0 { - ThreadIssueValueState::SameAsSuggested - } else if remaining == disclosure_types.len() { + if remaining { ThreadIssueValueState::SameAsOriginal } else { - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsSuggested } } Self::ModifyProjectDisclosure { @@ -559,7 +557,14 @@ impl ThreadIssueTarget { } } else { let Some(version) = version else { - return ThreadIssueValueState::DifferentToOriginal; + return if matches!( + target, + VersionIssueTarget::RemoveAdditionalFiles { .. } + ) { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + }; }; match target { @@ -644,20 +649,16 @@ impl ThreadIssueTarget { VersionIssueTarget::RemoveAdditionalFiles { file_ids, } => { - let remaining = file_ids - .iter() - .filter(|id| { - version.files.iter().any(|file| { - file.id.as_ref() == Some(id) - }) - }) - .count(); - if remaining == 0 { - ThreadIssueValueState::SameAsSuggested - } else if remaining == file_ids.len() { + let remaining = file_ids.iter().any(|id| { + version + .files + .iter() + .any(|file| file.id.as_ref() == Some(id)) + }); + if remaining { ThreadIssueValueState::SameAsOriginal } else { - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsSuggested } } VersionIssueTarget::ModifyAdditionalFileType { @@ -1083,6 +1084,10 @@ mod tests { icon.value_state(&context(&project), false), ThreadIssueValueState::DifferentToOriginal ); + assert_eq!( + icon.verdict(&context(&project), false, false), + ThreadIssueVerdict::Open + ); assert_eq!( tags.value_state(&context(&project), false), ThreadIssueValueState::SameAsOriginal @@ -1103,6 +1108,29 @@ mod tests { ); } + #[test] + fn icon_must_be_removed() { + let mut project = project(); + let target = ThreadIssueTarget::RemoveIcon; + + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.icon_url = Some("https://example.com/new-icon.png".to_string()); + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.icon_url = None; + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + #[test] fn gallery_addition_and_metadata_value_states() { let mut project = project(); @@ -1165,7 +1193,11 @@ mod tests { project.gallery.remove(0); assert_eq!( target.value_state(&context(&project), false), - ThreadIssueValueState::DifferentToOriginal + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open ); project.gallery.clear(); @@ -1173,16 +1205,43 @@ mod tests { target.value_state(&context(&project), false), ThreadIssueValueState::SameAsSuggested ); + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn removing_a_version_resolves_additional_file_removal() { + let project = project(); + let target = ThreadIssueTarget::Version { + version_id: VersionId(1), + version_number: "1.0.0".to_string(), + target: VersionIssueTarget::RemoveAdditionalFiles { + file_ids: non_empty(vec![FileId(1), FileId(2)]), + }, + }; + + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); } #[test] fn disclosure_value_states() { let project = project(); - let mut disclosures = vec![ProjectDisclosure::Advertisements { - note: Some("Original note".to_string()), - }]; + let mut disclosures = vec![ + ProjectDisclosure::Advertisements { + note: Some("Original note".to_string()), + }, + ProjectDisclosure::Archived { note: None }, + ]; let remove = ThreadIssueTarget::RemoveProjectDisclosures { - disclosure_types: non_empty(vec!["advertisements".to_string()]), + disclosure_types: non_empty(vec![ + "advertisements".to_string(), + "archived".to_string(), + ]), }; let note = ThreadIssueTarget::ModifyProjectDisclosureNote { disclosure_type: "advertisements".to_string(), @@ -1221,11 +1280,32 @@ mod tests { ThreadIssueValueState::SameAsSuggested ); + disclosures.pop(); + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + remove.verdict( + &ThreadIssueContext { + disclosures: &disclosures, + ..context(&project) + }, + true, + false, + ), + ThreadIssueVerdict::Open + ); + disclosures.clear(); assert_eq!( value_state(&remove, &disclosures), ThreadIssueValueState::SameAsSuggested ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); } #[test] @@ -1397,7 +1477,7 @@ mod tests { assert_eq!( gallery.verdict(&context(&project), true, false), - ThreadIssueVerdict::Addressed + ThreadIssueVerdict::Open ); project.gallery.remove(0); assert_eq!( From c99b5cbed359164b45110fa49730f464788d682d Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 9 Oct 2026 14:14:38 -0600 Subject: [PATCH 23/25] sqlx prepare --- ...75fc5971fd4550250196f3d8ee888a33b6dd2.json | 22 ------ ...4dfcad8f45097d93f41597cb9bf906171cf51.json | 24 ++++++ ...31473c354ea829a522d64370ad2580f27cf6b.json | 40 ++++++++++ ...0ea887db325063d1c3c310afa523f9cb74be0.json | 17 ----- ...577d104c8edd006d2e847486c9c87c10f8771.json | 16 ---- ...6951a4860fb3c94ab9a7b2686a68084fb830c.json | 76 ------------------- ...9457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json | 76 ------------------- ...f29615bd28598149d84b79a58966da9128875.json | 15 ---- ...ad484a6d41f40aea6699f7be73963b80a064a.json | 15 ---- 9 files changed, 64 insertions(+), 237 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json create mode 100644 apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json create mode 100644 apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json delete mode 100644 apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json delete mode 100644 apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json delete mode 100644 apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json delete mode 100644 apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json delete mode 100644 apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json delete mode 100644 apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json diff --git a/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json b/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json deleted file mode 100644 index 91d6cac8f21..00000000000 --- a/apps/labrinth/.sqlx/query-2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\tSELECT thread.mod_id\n\t\tFROM threads_issue_facets facet\n\t\tINNER JOIN threads_issues issue ON issue.id = facet.issue_id\n\t\tINNER JOIN threads thread ON thread.id = issue.thread_id\n\t\tWHERE facet.id = $1\n\t\t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mod_id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - true - ] - }, - "hash": "2779fc68483c57d2deddece457e75fc5971fd4550250196f3d8ee888a33b6dd2" -} diff --git a/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json b/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json new file mode 100644 index 00000000000..765e97bbd2a --- /dev/null +++ b/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT EXISTS (\n\t\t\t\tSELECT 1\n\t\t\t\tFROM threads_messages message\n\t\t\t\tWHERE message.thread_id = $1\n\t\t\t\t\tAND message.created > $3\n\t\t\t\t\tAND message.body->>'type' = 'text'\n\t\t\t\t\tAND COALESCE(message.body->>'private', 'false') = 'false'\n\t\t\t\t\tAND LENGTH(TRIM(message.body->>'body')) > 0\n\t\t\t\t\tAND message.author_id IN (\n\t\t\t\t\t\tSELECT member.user_id\n\t\t\t\t\t\tFROM mods project\n\t\t\t\t\t\tLEFT JOIN organizations organization\n\t\t\t\t\t\t\tON organization.id = project.organization_id\n\t\t\t\t\t\tINNER JOIN team_members member\n\t\t\t\t\t\t\tON member.team_id = project.team_id\n\t\t\t\t\t\t\t\tOR member.team_id = organization.team_id\n\t\t\t\t\t\tWHERE project.id = $2 AND member.accepted\n\t\t\t\t\t)\n\t\t\t) AS \"has_reply!\"\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_reply!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51" +} diff --git a/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json b/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json new file mode 100644 index 00000000000..76053489ea9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT thread.mod_id, issue.thread_id, issue.created_at,\n\t\t\tfacet.what AS \"what: sqlx::types::Json\"\n\t\tFROM threads_issue_facets facet\n\t\tINNER JOIN threads_issues issue ON issue.id = facet.issue_id\n\t\tINNER JOIN threads thread ON thread.id = issue.thread_id\n\t\tWHERE facet.id = $1\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "what: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false, + false, + false + ] + }, + "hash": "4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b" +} diff --git a/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json b/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json deleted file mode 100644 index 4cd491776d0..00000000000 --- a/apps/labrinth/.sqlx/query-682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n why,\n user_addressed,\n moderator_verified\n )\n VALUES ($1, $2, $3, $4, FALSE, FALSE)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "682bf781181fd8f363e811780f10ea887db325063d1c3c310afa523f9cb74be0" -} diff --git a/apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json b/apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json deleted file mode 100644 index 0e85e8a6854..00000000000 --- a/apps/labrinth/.sqlx/query-8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE threads_issues\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified)\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Bool", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "8bc7e6f01a2421236bbbc857904577d104c8edd006d2e847486c9c87c10f8771" -} diff --git a/apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json b/apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json deleted file mode 100644 index bbee4c6179d..00000000000 --- a/apps/labrinth/.sqlx/query-8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n issue.user_addressed,\n issue.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = $1\n ORDER BY issue.id, facet.id\n FOR UPDATE OF issue, facet\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "thread_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "created_by", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "why", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "user_addressed", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "moderator_verified", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "facet_id!", - "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "what: Json", - "type_info": "Jsonb" - }, - { - "ordinal": 9, - "name": "verdict", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "8ebfe988182c398c20b73850bdf6951a4860fb3c94ab9a7b2686a68084fb830c" -} diff --git a/apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json b/apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json deleted file mode 100644 index f2bad01c7f5..00000000000 --- a/apps/labrinth/.sqlx/query-b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n issue.user_addressed,\n issue.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = ANY($1)\n ORDER BY issue.created_at, issue.id, facet.id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "thread_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "created_by", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "why", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "user_addressed", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "moderator_verified", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "facet_id!", - "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "what: Json", - "type_info": "Jsonb" - }, - { - "ordinal": 9, - "name": "verdict", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "b220a004a2275f9f83a7ddd80279457fa7b9e71b8ae3510bd4e03351b6bd5ba6" -} diff --git a/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json b/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json deleted file mode 100644 index 02e5f0ce04c..00000000000 --- a/apps/labrinth/.sqlx/query-b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE threads_issue_facets\n SET verdict = $2\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "b400b21689eefde5b5aa523a7bff29615bd28598149d84b79a58966da9128875" -} diff --git a/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json b/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json deleted file mode 100644 index 8127ae5dde3..00000000000 --- a/apps/labrinth/.sqlx/query-fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE threads_issue_facets\n SET verdict = $2\n WHERE issue_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "fff91629c72c0a29bcb1e6c0a3aad484a6d41f40aea6699f7be73963b80a064a" -} From 3a33fc363d32d197aa0ebb39026a373609d2f44a Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:03:21 +0100 Subject: [PATCH 24/25] add gallery images takes IDs, not count --- apps/labrinth/src/models/v3/thread_issues.rs | 60 ++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 3dbe2537901..0ffdbfb0cfd 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -93,7 +93,7 @@ pub enum ThreadIssueTarget { links: HashMap, }, AddGalleryImages { - original_count: u32, + original_image_ids: Vec, }, ModifyGalleryImage { image_id: GalleryImageId, @@ -386,8 +386,10 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - Self::AddGalleryImages { original_count } => { - if project.gallery.len() > *original_count as usize { + Self::AddGalleryImages { original_image_ids } => { + if project.gallery.iter().any(|image| { + image.id.is_some_and(|id| !original_image_ids.contains(&id)) + }) { ThreadIssueValueState::DifferentToOriginal } else { ThreadIssueValueState::SameAsOriginal @@ -1135,7 +1137,11 @@ mod tests { fn gallery_addition_and_metadata_value_states() { let mut project = project(); let add = ThreadIssueTarget::AddGalleryImages { - original_count: project.gallery.len() as u32, + original_image_ids: project + .gallery + .iter() + .filter_map(|image| image.id) + .collect(), }; let metadata = ThreadIssueTarget::ModifyGalleryImage { image_id: GalleryImageId(1), @@ -1178,6 +1184,52 @@ mod tests { ); } + #[test] + fn gallery_addition_is_independent_of_removal() { + let mut project = project(); + let add = ThreadIssueTarget::AddGalleryImages { + original_image_ids: project + .gallery + .iter() + .filter_map(|image| image.id) + .collect(), + }; + let remove = ThreadIssueTarget::RemoveGalleryImages { + image_ids: non_empty(vec![GalleryImageId(1)]), + }; + let mut added = project.gallery.remove(0); + + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + + added.id = Some(GalleryImageId(3)); + project.gallery.push(added); + assert_eq!( + add.verdict(&context(&project), false, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Addressed + ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + + project.gallery.pop(); + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + } + #[test] fn all_gallery_targets_must_be_removed() { let mut project = project(); From 3c5c0b1ec6b37890d75e3af89ee91403af86e684 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:09:29 +0100 Subject: [PATCH 25/25] add gallery images auto-resolve --- apps/labrinth/src/models/v3/thread_issues.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs index 0ffdbfb0cfd..c3ea37e4b98 100644 --- a/apps/labrinth/src/models/v3/thread_issues.rs +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -94,6 +94,7 @@ pub enum ThreadIssueTarget { }, AddGalleryImages { original_image_ids: Vec, + auto_resolve: bool, }, ModifyGalleryImage { image_id: GalleryImageId, @@ -386,11 +387,18 @@ impl ThreadIssueTarget { ThreadIssueValueState::DifferentToOriginal } } - Self::AddGalleryImages { original_image_ids } => { + Self::AddGalleryImages { + original_image_ids, + auto_resolve, + } => { if project.gallery.iter().any(|image| { image.id.is_some_and(|id| !original_image_ids.contains(&id)) }) { - ThreadIssueValueState::DifferentToOriginal + if *auto_resolve { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + } } else { ThreadIssueValueState::SameAsOriginal } @@ -1137,6 +1145,7 @@ mod tests { fn gallery_addition_and_metadata_value_states() { let mut project = project(); let add = ThreadIssueTarget::AddGalleryImages { + auto_resolve: false, original_image_ids: project .gallery .iter() @@ -1188,6 +1197,7 @@ mod tests { fn gallery_addition_is_independent_of_removal() { let mut project = project(); let add = ThreadIssueTarget::AddGalleryImages { + auto_resolve: false, original_image_ids: project .gallery .iter()