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
+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
+181 -16
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;
@@ -216,7 +218,7 @@ pub async fn projects_get(
Ok(HttpResponse::Ok().json(projects))
}
/// Get a project.
/// Get a project.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = OK, body = Project))
@@ -351,7 +353,7 @@ pub struct EditProject {
}
#[allow(clippy::too_many_arguments)]
/// Update a project.
/// Update a project.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = NO_CONTENT))
@@ -1313,7 +1315,7 @@ pub async fn edit_project_categories(
// pub total_hits: usize,
// }
/// Search projects.
/// Search projects.
#[utoipa::path(
tag = "search",
get,
@@ -1360,7 +1362,7 @@ pub async fn project_search(
}
// for more complicated search queries
/// Search projects.
/// Search projects.
#[utoipa::path(
tag = "search",
request_body = serde_json::Value,
@@ -1380,7 +1382,7 @@ pub async fn project_search_post(
}
//checks the validity of a project id or slug
/// Check project availability.
/// Check project availability.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = OK, body = ProjectCheckResponse))
@@ -1420,7 +1422,7 @@ pub struct DependencyInfo {
pub versions: Vec<models::projects::Version>,
}
/// List project dependencies.
/// List project dependencies.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = OK, body = DependencyInfo))
@@ -1944,7 +1946,7 @@ pub struct Extension {
}
#[allow(clippy::too_many_arguments)]
/// Update a project icon.
/// Update a project icon.
#[utoipa::path(
context_path = "/project",
tag = "projects",
@@ -2108,7 +2110,7 @@ pub async fn project_icon_edit_internal(
Ok(HttpResponse::NoContent().body(""))
}
/// Delete a project icon.
/// Delete a project icon.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = NO_CONTENT))
@@ -2249,7 +2251,7 @@ pub struct GalleryCreateQuery {
}
#[allow(clippy::too_many_arguments)]
/// Add a gallery item.
/// Add a gallery item.
#[utoipa::path(
context_path = "/project",
tag = "projects",
@@ -2472,7 +2474,7 @@ pub struct GalleryEditQuery {
pub ordering: Option<i64>,
}
/// Update a gallery item.
/// Update a gallery item.
#[utoipa::path(
context_path = "/project",
tag = "projects",
@@ -2694,7 +2696,7 @@ pub struct GalleryDeleteQuery {
pub url: String,
}
/// Delete a gallery item.
/// Delete a gallery item.
#[utoipa::path(
context_path = "/project",
tag = "projects",
@@ -2849,7 +2851,7 @@ pub async fn delete_gallery_item_internal(
Ok(HttpResponse::NoContent().body(""))
}
/// Delete a project.
/// Delete a project.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = NO_CONTENT))
@@ -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,
@@ -3001,7 +3166,7 @@ pub async fn project_delete_internal(
}
}
/// Follow a project.
/// Follow a project.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = NO_CONTENT))
@@ -3108,7 +3273,7 @@ pub async fn project_follow_internal(
}
}
/// Unfollow a project.
/// Unfollow a project.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = NO_CONTENT))
@@ -3212,7 +3377,7 @@ pub async fn project_unfollow_internal(
}
}
/// Get a project's organization.
/// Get a project's organization.
#[utoipa::path(
context_path = "/project",
tag = "projects", responses((status = OK, body = models::organizations::Organization))