rejected projects go to ghost on delete (#7107)

* draft: rejected projects go to ghost on delete

* don't free slug

* fix sqlx

* free slugs but different

* add auto reject for processing items given to ghost, as well as send thread messages with a little info on who it came from
This commit is contained in:
Prospector
2026-08-12 23:54:57 +00:00
committed by GitHub
parent 552bc3f739
commit 2fd4495104
9 changed files with 436 additions and 17 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET organization_id = NULL, slug = COALESCE($1, slug)\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mod_follows\n WHERE mod_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"id!\",\n m.status AS \"status!\",\n m.slug,\n t.id AS \"thread_id!\"\n FROM mods m\n INNER JOIN team_members tm\n ON tm.team_id = m.team_id\n AND tm.user_id = $1\n AND tm.is_owner = TRUE\n INNER JOIN threads t ON t.mod_id = m.id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "slug",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "thread_id!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM team_members\n WHERE team_id = $1\n RETURNING user_id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1 FROM mods\n WHERE\n (slug = LOWER($1) OR text_id_lower = LOWER($1))\n AND id != $2\n ) AS \"exists!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f"
}
+112 -1
View File
@@ -1,15 +1,19 @@
use super::ids::{DBProjectId, DBUserId};
use super::{DBCollectionId, DBReportId, DBThreadId};
use crate::database::models::charge_item::DBCharge;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::database::models::{DBOrganizationId, DatabaseError};
use crate::database::{PgTransaction, models};
use crate::models::billing::ChargeStatus;
use crate::models::projects::ProjectStatus;
use crate::models::threads::MessageBody;
use crate::models::users::Badges;
use crate::util::error::Context;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
@@ -593,12 +597,120 @@ impl DBUser {
.wrap_err("failed to get user by ID")?;
if let Some(delete_user) = user {
let username = delete_user.username.clone();
DBUser::clear_caches(&[(id, Some(delete_user.username))], redis)
.await
.wrap_err("failed to clear caches")?;
let deleted_user: DBUserId =
crate::models::users::DELETED_USER.into();
let user_id_str = ariadne::ids::UserId::from(id).to_string();
let owned_projects = sqlx::query!(
r#"
SELECT
m.id AS "id!",
m.status AS "status!",
m.slug,
t.id AS "thread_id!"
FROM mods m
INNER JOIN team_members tm
ON tm.team_id = m.team_id
AND tm.user_id = $1
AND tm.is_owner = TRUE
INNER JOIN threads t ON t.mod_id = m.id
"#,
id as DBUserId,
)
.fetch(&mut *transaction)
.try_collect::<Vec<_>>()
.await
.wrap_err("failed to fetch projects owned by deleted user")?;
for project in &owned_projects {
let thread_id = DBThreadId(project.thread_id);
ThreadMessageBuilder {
author_id: Some(deleted_user),
body: MessageBody::Text {
body: format!(
"Project transferred to Ghost when user account `{username}` (`{user_id_str}`) was deleted"
),
private: true,
replying_to: None,
associated_images: Vec::new(),
},
thread_id,
hide_identity: false,
}
.insert(&mut *transaction)
.await
.wrap_err(
"failed to insert project transfer thread message",
)?;
if ProjectStatus::from_string(&project.status)
== ProjectStatus::Processing
{
ThreadMessageBuilder {
author_id: Some(deleted_user),
body: MessageBody::Text {
body: format!(
"Automatically rejected when user account `{username}` (`{user_id_str}`) was deleted"
),
private: true,
replying_to: None,
associated_images: Vec::new(),
},
thread_id,
hide_identity: false,
}
.insert(&mut *transaction)
.await
.wrap_err(
"failed to insert automatic rejection thread message",
)?;
ThreadMessageBuilder {
author_id: Some(deleted_user),
body: MessageBody::StatusChange {
new_status: ProjectStatus::Rejected,
old_status: ProjectStatus::Processing,
},
thread_id,
hide_identity: false,
}
.insert(&mut *transaction)
.await
.wrap_err(
"failed to insert automatic rejection status change",
)?;
sqlx::query!(
r#"
UPDATE mods
SET status = $1
WHERE id = $2
"#,
ProjectStatus::Rejected.as_str(),
project.id,
)
.execute(&mut *transaction)
.await
.wrap_err(
"failed to reject processing project owned by deleted user",
)?;
}
models::DBProject::clear_cache(
DBProjectId(project.id),
project.slug.clone(),
None,
redis,
)
.await
.wrap_err("failed to clear project cache")?;
}
sqlx::query!(
"
@@ -626,7 +738,6 @@ impl DBUser {
.await
.wrap_err("failed to update versions author_id")?;
use futures::TryStreamExt;
let notifications: Vec<i64> = sqlx::query!(
"
SELECT n.id FROM notifications n
+166 -1
View File
@@ -22,8 +22,9 @@ use crate::models::pats::Scopes;
use crate::models::projects::{
MonetizationStatus, Project, ProjectStatus, SideTypesMigrationReviewStatus,
};
use crate::models::teams::ProjectPermissions;
use crate::models::teams::{DEFAULT_ROLE, ProjectPermissions};
use crate::models::threads::MessageBody;
use crate::models::users::DELETED_USER;
use crate::models::{self, exp};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
@@ -40,6 +41,7 @@ use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
use xredis::RedisPool;
@@ -2934,6 +2936,169 @@ pub async fn project_delete_internal(
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
// rejected & withheld projects are transferred to ghost so moderation data is preserved
if matches!(
project.inner.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 candidate = format!(
"{slug}--deleted-{}",
ProjectId::from(project.inner.id)
);
if candidate.len() <= 255 {
let taken = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1 FROM mods
WHERE
(slug = LOWER($1) OR text_id_lower = LOWER($1))
AND id != $2
) AS "exists!"
"#,
candidate,
project.inner.id as db_ids::DBProjectId,
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("checking deleted slug availability")?
.exists;
if !taken {
Some(candidate.to_lowercase())
} else {
None
}
} else {
None
}
} else {
None
};
sqlx::query!(
"
UPDATE mods
SET organization_id = NULL, slug = COALESCE($1, slug)
WHERE id = $2
",
deleted_slug,
project.inner.id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"failed to detach project from organization and update slug",
)?;
let affected_user_ids = sqlx::query!(
"
DELETE FROM team_members
WHERE team_id = $1
RETURNING user_id
",
project.inner.team_id as db_ids::DBTeamId,
)
.fetch(&mut transaction)
.map_ok(|x| db_ids::DBUserId(x.user_id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to remove project team members")?;
let new_member_id = db_ids::generate_team_member_id(&mut transaction)
.await
.wrap_internal_err("failed to generate team member ID")?;
DBTeamMember {
id: new_member_id,
team_id: project.inner.team_id,
user_id: deleted_user,
role: DEFAULT_ROLE.to_owned(),
is_owner: true,
permissions: ProjectPermissions::all(),
organization_permissions: None,
accepted: true,
payouts_split: Decimal::ZERO,
ordering: 0,
}
.insert(&mut transaction)
.await
.wrap_internal_err("failed to transfer project ownership to ghost")?;
ThreadMessageBuilder {
author_id: Some(deleted_user),
body: MessageBody::Text {
body: format!(
"Project transferred to Ghost when user account `{}` (`{}`) deleted this project",
user.username,
user.id
),
private: true,
replying_to: None,
associated_images: Vec::new(),
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await
.wrap_internal_err(
"failed to insert project transfer thread message",
)?;
sqlx::query!(
"
DELETE FROM collections_mods
WHERE mod_id = $1
",
project.inner.id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to delete project from collections_mods")?;
sqlx::query!(
"
DELETE FROM mod_follows
WHERE mod_id = $1
",
project.inner.id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to delete project followers")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
let mut cache_user_ids = affected_user_ids;
cache_user_ids.push(deleted_user);
db_models::DBUser::clear_project_cache(&cache_user_ids, &redis)
.await
.wrap_internal_err("failed to clear user project cache")?;
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())
.await;
return Ok(());
}
delphi::tech_review_sync::sync_deleted_project_tech_review_exit(
project.inner.id,
&mut transaction,