diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 9b56f2b4..65478bb8 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -64,9 +64,7 @@ impl Database { } /// Returns the number of unapproved papers - pub async fn get_unapproved_papers_count( - &self, - ) -> Result { + pub async fn get_unapproved_papers_count(&self) -> Result { let count: (i64,) = sqlx::query_as(queries::GET_UNAPPROVED_COUNT) .fetch_one(&self.connection) .await?; @@ -243,6 +241,40 @@ impl Database { } } + /// Gets all soft-deleted papers from the database + pub async fn get_soft_deleted_papers(&self) -> Result, sqlx::Error> { + let query_sql = queries::get_get_soft_deleted_papers_query(); + let papers: Vec = sqlx::query_as(&query_sql) + .fetch_all(&self.connection) + .await?; + + Ok(papers + .iter() + .map(|qp| qp::AdminDashboardQP::from(qp.clone())) + .collect()) + } + + /// Permanently deletes a paper from the database + pub async fn hard_delete(&self, id: i32) -> Result, color_eyre::eyre::Error> { + let mut tx = self.connection.begin().await?; + let rows_affected = sqlx::query(queries::HARD_DELETE_BY_ID) + .bind(id) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows_affected > 1 { + tx.rollback().await?; + return Err(eyre!( + "Error: {} (> 1) papers were deleted. Rolling back.", + rows_affected + )) + } else if rows_affected < 1 { + tx.rollback().await?; + return Err(eyre!("Error: No papers were deleted.")) + } + Ok(tx) + } + /// Returns all papers that match one or more of the specified properties exactly. `course_name` is required, other properties are optional. pub async fn get_similar_papers( &self, diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 93d78d08..58dc245b 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -69,6 +69,15 @@ pub const SOFT_DELETE_BY_ID: &str = pub const SOFT_DELETE_ANY_BY_ID: &str = "UPDATE iqps SET approve_status=false, is_deleted = true WHERE id=$1"; +/// Hard deletes a paper (removes it from the database) +pub const HARD_DELETE_BY_ID: &str = + "DELETE FROM iqps WHERE id=$1"; + +/// Gets all soft-deleted papers ([`crate::db::models::DBAdminDashboardQP`]) from the database +pub fn get_get_soft_deleted_papers_query() -> String { + format!("SELECT {} FROM iqps WHERE is_deleted=true", ADMIN_DASHBOARD_QP_FIELDS) +} + /// Get a paper ([`crate::db::models::DBAdminDashboardQP`]) with the given id (first parameter `$1`) pub fn get_get_paper_by_id_query() -> String { format!( diff --git a/backend/src/routing/handlers.rs b/backend/src/routing/handlers.rs index e727ce7f..ad199f34 100644 --- a/backend/src/routing/handlers.rs +++ b/backend/src/routing/handlers.rs @@ -54,6 +54,21 @@ pub async fn get_unapproved( )) } +/// Gets all papers which have been soft-deleted. +pub async fn get_trash(State(state): State) -> HandlerReturn> { + let papers: Vec = state.db.get_soft_deleted_papers().await?; + + let papers = papers + .iter() + .map(|paper| paper.clone().with_url(&state.env_vars)) + .collect::, color_eyre::eyre::Error>>()?; + + Ok(BackendResponse::ok( + format!("Successfully fetched {} papers.", papers.len()), + papers, + )) +} + /// Fetches a paper by id. pub async fn get_paper_details( State(state): State, @@ -423,11 +438,7 @@ pub async fn upload( total_count ); - let _ = send_slack_message( - &state.env_vars.slack_webhook_url, - &message, - ) - .await; + let _ = send_slack_message(&state.env_vars.slack_webhook_url, &message).await; Ok(BackendResponse::ok( format!("Successfully processed {} files", upload_statuses.len()), @@ -463,6 +474,68 @@ pub async fn delete( } } +#[derive(Deserialize)] +/// The request format for the hard delete endpoint +pub struct HardDeleteReq { + ids: Vec, +} + +#[derive(Serialize)] +/// The status of a paper to be deleted +pub struct DeleteStatus { + id: i32, + status: Status, + message: String +} + +/// Hard deletes papers from a list of ids. +/// +/// Request format - [`HardDeleteReq`] +pub async fn hard_delete( + State(state): State, + Json(body): Json, +) -> HandlerReturn> { + let mut delete_statuses = Vec::::new(); + let mut deleted_count = 0; + for id in body.ids { + if let Ok(paper) = state.db.get_paper_by_id(id).await { + let tx = state.db.hard_delete(id).await?; + let filepath = state.env_vars.paths.get_path_from_slug(&paper.qp.filelink); + if fs::remove_file(&filepath).await.is_ok() { + if tx.commit().await.is_ok() { + delete_statuses.push(DeleteStatus { + id, + status: Status::Success, + message: "Successfully hard deleted the paper.".into(), + }); + deleted_count += 1; + } else { + delete_statuses.push(DeleteStatus { + id, + status: Status::Error, + message: "Error committing the transaction.".into(), + }); + } + } else { + tx.rollback().await?; + delete_statuses.push(DeleteStatus { + id, + status: Status::Error, + message: "Failed to delete file.".into(), + }); + } + } + } + + let message = if deleted_count > 0 { + format!("Successfully hard deleted {} papers.", deleted_count) + } else { + "No papers were deleted.".into() + }; + + Ok(BackendResponse::ok(message, delete_statuses)) +} + /// Fetches all question papers that match one or more properties specified. `course_name` is compulsory. /// /// # Request Query Parameters diff --git a/backend/src/routing/mod.rs b/backend/src/routing/mod.rs index 5248c4b2..f60e9886 100644 --- a/backend/src/routing/mod.rs +++ b/backend/src/routing/mod.rs @@ -31,10 +31,12 @@ pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router { axum::Router::new() .route("/unapproved", axum::routing::get(handlers::get_unapproved)) + .route("/trash", axum::routing::get(handlers::get_trash)) .route("/details", axum::routing::get(handlers::get_paper_details)) .route("/profile", axum::routing::get(handlers::profile)) .route("/edit", axum::routing::post(handlers::edit)) .route("/delete", axum::routing::post(handlers::delete)) + .route("/harddelete", axum::routing::post(handlers::hard_delete)) .route("/similar", axum::routing::get(handlers::similar)) .route_layer(axum::middleware::from_fn_with_state( state.clone(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cb8e5a1a..47d7c928 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import SearchPage from "./pages/SearchPage"; import UploadPage from "./pages/UploadPage"; import OAuthPage from "./pages/OAuthPage"; +import AdminLayout from "./pages/AdminLayout"; import AdminDashboard from "./pages/AdminDashboard"; +import TrashPage from "./pages/TrashPage"; import { AuthProvider } from "./utils/auth"; import { Footer } from "./components/Common/Common"; import { Toaster } from "react-hot-toast"; @@ -25,10 +27,16 @@ function App() { path="/oauth" element={} /> - } - /> + }> + } + /> + } + /> +