From 9ecbe8eaa60cc9aff561c991c57ba291f6173c4f Mon Sep 17 00:00:00 2001 From: Sychic <6166773+Prospector@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:26:15 -0400 Subject: [PATCH] feat(labrinth): granular disclosure locking (#7108) * feat(labrinth): granular disclosure locking * chore(labrinth): update query cache --- ...8a8c006dcbfae6e06b568872a8fa2cfe90a7e.json | 23 ------ ...70699b86e431e6a500c635d37da8cd16022a1.json | 18 ----- ...89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json} | 12 ++- ...a136ddbe802cd3a4c649e4982d9f28ae097ed.json | 34 --------- ...339e87357ba1f464f8300aba713fcfda20d52.json | 29 +++++++ ...61a8dff02ed316fc0b98754605a6e16e8e846.json | 23 ------ ...5705f8efc09ac21e0cf9a15c4cce019aaeb05.json | 23 ------ ...7da1a33a059b0a1d00b51e27b4a5399492b13.json | 15 ---- ...9d3d134eddae9c5f20bffde64104d7c295076.json | 19 +++++ ...d0d2e5a708e59b897961f00164a28ab6294a4.json | 18 ----- ...16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json | 52 ------------- .../20260811120000_disclosure-lock-status.sql | 6 ++ .../models/project_disclosure_item.rs | 55 ++++++++++---- apps/labrinth/src/models/v3/disclosures.rs | 32 ++++++++ apps/labrinth/src/routes/v3/disclosures.rs | 75 ++++++++++++++----- apps/labrinth/src/routes/v3/projects.rs | 8 +- 16 files changed, 199 insertions(+), 243 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e.json delete mode 100644 apps/labrinth/.sqlx/query-412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1.json rename apps/labrinth/.sqlx/{query-588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442.json => query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json} (75%) delete mode 100644 apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json create mode 100644 apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json delete mode 100644 apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json delete mode 100644 apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json delete mode 100644 apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json create mode 100644 apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json delete mode 100644 apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json delete mode 100644 apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json create mode 100644 apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql diff --git a/apps/labrinth/.sqlx/query-200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e.json b/apps/labrinth/.sqlx/query-200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e.json deleted file mode 100644 index e167c21eca..0000000000 --- a/apps/labrinth/.sqlx/query-200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator AND deleted_at IS NULL\n\t\t\tLIMIT 1\n\t\t\t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e" -} diff --git a/apps/labrinth/.sqlx/query-412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1.json b/apps/labrinth/.sqlx/query-412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1.json deleted file mode 100644 index bbd9018afe..0000000000 --- a/apps/labrinth/.sqlx/query-412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5,\n\t\t\t\tdeleted_at = NULL\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Jsonb", - "Int8", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1" -} diff --git a/apps/labrinth/.sqlx/query-588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442.json b/apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json similarity index 75% rename from apps/labrinth/.sqlx/query-588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442.json rename to apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json index 4453a40e9b..b1003df726 100644 --- a/apps/labrinth/.sqlx/query-588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442.json +++ b/apps/labrinth/.sqlx/query-43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator, deleted_at\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND ($2 OR deleted_at IS NULL)\n\t\t\tORDER BY updated_at DESC\n\t\t\t", + "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator, deleted_at, lock_status\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND ($2 OR deleted_at IS NULL)\n\t\t\tORDER BY updated_at DESC\n\t\t\t", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "deleted_at", "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "lock_status", + "type_info": "Text" } ], "parameters": { @@ -52,8 +57,9 @@ false, false, false, - true + true, + false ] }, - "hash": "588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442" + "hash": "43a668954d69d7d450f73077d6fa89fd9d47eedd1e627079ddc3cb89bc9ec4f2" } diff --git a/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json b/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json deleted file mode 100644 index 212faa2dca..0000000000 --- a/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "project_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "metadata", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed" -} diff --git a/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json b/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json new file mode 100644 index 0000000000..8346ed4b2a --- /dev/null +++ b/apps/labrinth/.sqlx/query-60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT type AS \"disclosure_type!\", lock_status\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2)\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "disclosure_type!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "lock_status", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "60decf7fbabdebf749dba72cd7e339e87357ba1f464f8300aba713fcfda20d52" +} diff --git a/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json b/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json deleted file mode 100644 index 153b859c15..0000000000 --- a/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator\n\t\t\tLIMIT 1\n\t\t\t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846" -} diff --git a/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json b/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json deleted file mode 100644 index 5d5edc88ae..0000000000 --- a/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2)\n\t\t\t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "project_id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8Array" - ] - }, - "nullable": [ - false - ] - }, - "hash": "87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05" -} diff --git a/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json b/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json deleted file mode 100644 index e43bc36603..0000000000 --- a/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tDELETE FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = $2\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13" -} diff --git a/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json b/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json new file mode 100644 index 0000000000..6329652107 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator, lock_status)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5,\n\t\t\t\tdeleted_at = NULL,\n\t\t\t\tlock_status = $6\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Jsonb", + "Int8", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a15df2b9e6cdedc9d811d58e9de9d3d134eddae9c5f20bffde64104d7c295076" +} diff --git a/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json b/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json deleted file mode 100644 index f9227fe06e..0000000000 --- a/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Jsonb", - "Int8", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4" -} diff --git a/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json b/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json deleted file mode 100644 index 79515fb7ca..0000000000 --- a/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "project_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "disclosure_type!", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "metadata", - "type_info": "Jsonb" - }, - { - "ordinal": 3, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "updated_by", - "type_info": "Int8" - }, - { - "ordinal": 5, - "name": "set_by_moderator", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10" -} diff --git a/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql b/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql new file mode 100644 index 0000000000..d75db77a62 --- /dev/null +++ b/apps/labrinth/migrations/20260811120000_disclosure-lock-status.sql @@ -0,0 +1,6 @@ +ALTER TABLE project_disclosures + ADD COLUMN lock_status TEXT NOT NULL DEFAULT 'unlocked'; + +UPDATE project_disclosures +SET lock_status = 'fully_locked' +WHERE set_by_moderator; diff --git a/apps/labrinth/src/database/models/project_disclosure_item.rs b/apps/labrinth/src/database/models/project_disclosure_item.rs index 0f190278e1..ef8cdf0866 100644 --- a/apps/labrinth/src/database/models/project_disclosure_item.rs +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -1,11 +1,14 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ database::models::{DBProjectId, DBUserId, DatabaseError}, - models::v3::disclosures::{ProjectDisclosure, ProjectDisclosureType}, + models::v3::disclosures::{ + DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureType, + }, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -16,6 +19,7 @@ pub struct DBProjectDisclosure { pub updated_by: DBUserId, pub set_by_moderator: bool, pub deleted_at: Option>, + pub lock_status: DisclosureLockStatus, } impl DBProjectDisclosure { @@ -32,20 +36,22 @@ impl DBProjectDisclosure { sqlx::query!( r#" - INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator, lock_status) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (project_id, type) DO UPDATE SET metadata = $3, updated_at = now(), updated_by = $4, set_by_moderator = $5, - deleted_at = NULL + deleted_at = NULL, + lock_status = $6 "#, self.project_id as DBProjectId, disclosure_type, metadata, self.updated_by as DBUserId, self.set_by_moderator, + <&'static str>::from(self.lock_status), ) .execute(exec) .await?; @@ -60,7 +66,7 @@ impl DBProjectDisclosure { ) -> Result, DatabaseError> { let rows = sqlx::query!( r#" - SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator, deleted_at + SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator, deleted_at, lock_status FROM project_disclosures WHERE project_id = $1 AND ($2 OR deleted_at IS NULL) ORDER BY updated_at DESC @@ -88,6 +94,14 @@ impl DBProjectDisclosure { updated_by: DBUserId(row.updated_by), set_by_moderator: row.set_by_moderator, deleted_at: row.deleted_at, + lock_status: DisclosureLockStatus::from_str( + &row.lock_status, + ) + .map_err(|e| { + DatabaseError::Internal(eyre::Report::new(e).wrap_err( + "failed to parse project disclosure lock status", + )) + })?, }) }) .collect() @@ -115,24 +129,37 @@ impl DBProjectDisclosure { Ok(rows.into_iter().map(DBProjectId).collect()) } - pub async fn any_set_by_moderator( + pub async fn get_lock_statuses_for_project( project_id: DBProjectId, types: &[String], exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, - ) -> Result { - let existing = sqlx::query_scalar!( + ) -> Result, DatabaseError> { + let rows = sqlx::query!( r#" - SELECT 1 FROM project_disclosures - WHERE project_id = $1 AND type = ANY($2) AND set_by_moderator AND deleted_at IS NULL - LIMIT 1 + SELECT type AS "disclosure_type!", lock_status + FROM project_disclosures + WHERE project_id = $1 AND type = ANY($2) "#, project_id as DBProjectId, types, ) - .fetch_optional(exec) + .fetch_all(exec) .await?; - Ok(existing.is_some()) + rows.into_iter() + .map(|row| { + let lock_status = DisclosureLockStatus::from_str( + &row.lock_status, + ) + .map_err(|e| { + DatabaseError::Internal(eyre::Report::new(e).wrap_err( + "failed to parse project disclosure lock status", + )) + })?; + + Ok((row.disclosure_type, lock_status)) + }) + .collect() } pub async fn remove( diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 681331fce3..52f6c06187 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -77,11 +77,42 @@ impl ProjectDisclosure { } } +#[derive( + Debug, + Clone, + Copy, + Default, + Serialize, + Deserialize, + ToSchema, + IntoStaticStr, + EnumString, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum DisclosureLockStatus { + #[default] + Unlocked, + CannotDisable, + FullyLocked, +} + +impl DisclosureLockStatus { + pub fn allows_edit(self) -> bool { + !matches!(self, Self::FullyLocked) + } + + pub fn allows_removal(self) -> bool { + matches!(self, Self::Unlocked) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ProjectDisclosureData { #[serde(flatten)] pub disclosure: ProjectDisclosure, pub set_by_moderator: bool, + pub lock_status: DisclosureLockStatus, pub updated_at: DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub updated_by: Option, @@ -100,6 +131,7 @@ impl ProjectDisclosureData { Self { disclosure: value.disclosure, set_by_moderator: value.set_by_moderator, + lock_status: value.lock_status, updated_at: value.updated_at, updated_by, deleted_at: value.deleted_at, diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 3f81667d7b..40fa45d93e 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -10,7 +10,8 @@ use crate::auth::get_user_from_headers; use crate::database::{DBProject, models as db_models}; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::disclosures::{ - ProjectDisclosure, ProjectDisclosureData, ProjectDisclosureType, + DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureData, + ProjectDisclosureType, }; use crate::models::pats::Scopes; use crate::models::teams::ProjectPermissions; @@ -99,6 +100,8 @@ pub async fn get_project_disclosures( pub struct ModifyProjectDisclosures { pub set: Vec, pub remove: Vec, + #[serde(default)] + pub lock_status: Option, } #[utoipa::path( @@ -164,28 +167,57 @@ pub async fn modify_project_disclosures( ))); } - if !user.role.is_mod() { - let modified_types = body - .set - .iter() - .map(|disclosure| { - <&'static str>::from(ProjectDisclosureType::from(disclosure)) - .to_owned() - }) - .chain(body.remove.iter().cloned()) - .collect::>(); + let is_moderator = user.role.is_mod(); - if db_models::DBProjectDisclosure::any_set_by_moderator( + if body.lock_status.is_some() && !is_moderator { + return Err(ApiError::Auth(eyre!( + "only moderators can set the lock status of a disclosure" + ))); + } + + let modified_types = body + .set + .iter() + .map(|disclosure| { + <&'static str>::from(ProjectDisclosureType::from(disclosure)) + .to_owned() + }) + .chain(body.remove.iter().cloned()) + .collect::>(); + + let lock_statuses = + db_models::DBProjectDisclosure::get_lock_statuses_for_project( project.inner.id, &modified_types, &**pool, ) .await - .wrap_internal_err("failed to check moderator disclosures")? - { - return Err(ApiError::Auth(eyre!( - "you cannot modify a disclosure set by a moderator" - ))); + .wrap_internal_err("failed to fetch disclosure lock statuses")?; + + let lock_status_of = |disclosure_type: &str| { + lock_statuses + .get(disclosure_type) + .copied() + .unwrap_or_default() + }; + + if !is_moderator { + for disclosure in &body.set { + let disclosure_type = + <&'static str>::from(ProjectDisclosureType::from(disclosure)); + if !lock_status_of(disclosure_type).allows_edit() { + return Err(ApiError::Auth(eyre!( + "you cannot modify the `{disclosure_type}` disclosure, it is locked by a moderator" + ))); + } + } + + for disclosure_type in &body.remove { + if !lock_status_of(disclosure_type).allows_removal() { + return Err(ApiError::Auth(eyre!( + "you cannot remove the `{disclosure_type}` disclosure, it is locked by a moderator" + ))); + } } } @@ -195,13 +227,20 @@ pub async fn modify_project_disclosures( .wrap_internal_err("starting database transaction")?; for disclosure in body.set { + let disclosure_type = + <&'static str>::from(ProjectDisclosureType::from(&disclosure)); + + let lock_status = + body.lock_status.unwrap_or(lock_status_of(disclosure_type)); + db_models::DBProjectDisclosure { project_id: project.inner.id, disclosure, updated_at: Utc::now(), updated_by: user.id.into(), - set_by_moderator: user.role.is_mod(), + set_by_moderator: is_moderator, deleted_at: None, + lock_status, } .upsert(&mut transaction) .await diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 5c2faea401..f996bb6058 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -15,7 +15,9 @@ use crate::database::{self, models as db_models}; use crate::database::{PgPool, PgTransaction, ReadOnlyPgPool}; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureType}; +use crate::models::disclosures::{ + DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureType, +}; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; @@ -517,6 +519,7 @@ pub async fn project_edit_internal( updated_by: user.id.into(), set_by_moderator: user.role.is_mod(), deleted_at: None, + lock_status: DisclosureLockStatus::Unlocked, } .upsert(&mut transaction) .await @@ -726,7 +729,8 @@ pub async fn project_edit_internal( if sync_archival_disclosure && archival_disclosure.is_some_and(|disclosure| { - user.role.is_mod() || !disclosure.set_by_moderator + user.role.is_mod() + || disclosure.lock_status.allows_removal() }) { db_models::DBProjectDisclosure::remove(