Skip to content

Commit 09947a5

Browse files
Merge pull request #128 from dipamsen/hard-delete
add trash page
2 parents 49d4be5 + 18142f5 commit 09947a5

File tree

10 files changed

+504
-35
lines changed

10 files changed

+504
-35
lines changed

backend/src/db/mod.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ impl Database {
6464
}
6565

6666
/// Returns the number of unapproved papers
67-
pub async fn get_unapproved_papers_count(
68-
&self,
69-
) -> Result<i64, sqlx::Error> {
67+
pub async fn get_unapproved_papers_count(&self) -> Result<i64, sqlx::Error> {
7068
let count: (i64,) = sqlx::query_as(queries::GET_UNAPPROVED_COUNT)
7169
.fetch_one(&self.connection)
7270
.await?;
@@ -243,6 +241,40 @@ impl Database {
243241
}
244242
}
245243

244+
/// Gets all soft-deleted papers from the database
245+
pub async fn get_soft_deleted_papers(&self) -> Result<Vec<AdminDashboardQP>, sqlx::Error> {
246+
let query_sql = queries::get_get_soft_deleted_papers_query();
247+
let papers: Vec<models::DBAdminDashboardQP> = sqlx::query_as(&query_sql)
248+
.fetch_all(&self.connection)
249+
.await?;
250+
251+
Ok(papers
252+
.iter()
253+
.map(|qp| qp::AdminDashboardQP::from(qp.clone()))
254+
.collect())
255+
}
256+
257+
/// Permanently deletes a paper from the database
258+
pub async fn hard_delete(&self, id: i32) -> Result<Transaction<'_, Postgres>, color_eyre::eyre::Error> {
259+
let mut tx = self.connection.begin().await?;
260+
let rows_affected = sqlx::query(queries::HARD_DELETE_BY_ID)
261+
.bind(id)
262+
.execute(&mut *tx)
263+
.await?
264+
.rows_affected();
265+
if rows_affected > 1 {
266+
tx.rollback().await?;
267+
return Err(eyre!(
268+
"Error: {} (> 1) papers were deleted. Rolling back.",
269+
rows_affected
270+
))
271+
} else if rows_affected < 1 {
272+
tx.rollback().await?;
273+
return Err(eyre!("Error: No papers were deleted."))
274+
}
275+
Ok(tx)
276+
}
277+
246278
/// Returns all papers that match one or more of the specified properties exactly. `course_name` is required, other properties are optional.
247279
pub async fn get_similar_papers(
248280
&self,

backend/src/db/queries.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ pub const SOFT_DELETE_BY_ID: &str =
6969
pub const SOFT_DELETE_ANY_BY_ID: &str =
7070
"UPDATE iqps SET approve_status=false, is_deleted = true WHERE id=$1";
7171

72+
/// Hard deletes a paper (removes it from the database)
73+
pub const HARD_DELETE_BY_ID: &str =
74+
"DELETE FROM iqps WHERE id=$1";
75+
76+
/// Gets all soft-deleted papers ([`crate::db::models::DBAdminDashboardQP`]) from the database
77+
pub fn get_get_soft_deleted_papers_query() -> String {
78+
format!("SELECT {} FROM iqps WHERE is_deleted=true", ADMIN_DASHBOARD_QP_FIELDS)
79+
}
80+
7281
/// Get a paper ([`crate::db::models::DBAdminDashboardQP`]) with the given id (first parameter `$1`)
7382
pub fn get_get_paper_by_id_query() -> String {
7483
format!(

backend/src/routing/handlers.rs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ pub async fn get_unapproved(
5454
))
5555
}
5656

57+
/// Gets all papers which have been soft-deleted.
58+
pub async fn get_trash(State(state): State<RouterState>) -> HandlerReturn<Vec<AdminDashboardQP>> {
59+
let papers: Vec<AdminDashboardQP> = state.db.get_soft_deleted_papers().await?;
60+
61+
let papers = papers
62+
.iter()
63+
.map(|paper| paper.clone().with_url(&state.env_vars))
64+
.collect::<Result<Vec<qp::AdminDashboardQP>, color_eyre::eyre::Error>>()?;
65+
66+
Ok(BackendResponse::ok(
67+
format!("Successfully fetched {} papers.", papers.len()),
68+
papers,
69+
))
70+
}
71+
5772
/// Fetches a paper by id.
5873
pub async fn get_paper_details(
5974
State(state): State<RouterState>,
@@ -423,11 +438,7 @@ pub async fn upload(
423438
total_count
424439
);
425440

426-
let _ = send_slack_message(
427-
&state.env_vars.slack_webhook_url,
428-
&message,
429-
)
430-
.await;
441+
let _ = send_slack_message(&state.env_vars.slack_webhook_url, &message).await;
431442

432443
Ok(BackendResponse::ok(
433444
format!("Successfully processed {} files", upload_statuses.len()),
@@ -463,6 +474,68 @@ pub async fn delete(
463474
}
464475
}
465476

477+
#[derive(Deserialize)]
478+
/// The request format for the hard delete endpoint
479+
pub struct HardDeleteReq {
480+
ids: Vec<i32>,
481+
}
482+
483+
#[derive(Serialize)]
484+
/// The status of a paper to be deleted
485+
pub struct DeleteStatus {
486+
id: i32,
487+
status: Status,
488+
message: String
489+
}
490+
491+
/// Hard deletes papers from a list of ids.
492+
///
493+
/// Request format - [`HardDeleteReq`]
494+
pub async fn hard_delete(
495+
State(state): State<RouterState>,
496+
Json(body): Json<HardDeleteReq>,
497+
) -> HandlerReturn<Vec<DeleteStatus>> {
498+
let mut delete_statuses = Vec::<DeleteStatus>::new();
499+
let mut deleted_count = 0;
500+
for id in body.ids {
501+
if let Ok(paper) = state.db.get_paper_by_id(id).await {
502+
let tx = state.db.hard_delete(id).await?;
503+
let filepath = state.env_vars.paths.get_path_from_slug(&paper.qp.filelink);
504+
if fs::remove_file(&filepath).await.is_ok() {
505+
if tx.commit().await.is_ok() {
506+
delete_statuses.push(DeleteStatus {
507+
id,
508+
status: Status::Success,
509+
message: "Successfully hard deleted the paper.".into(),
510+
});
511+
deleted_count += 1;
512+
} else {
513+
delete_statuses.push(DeleteStatus {
514+
id,
515+
status: Status::Error,
516+
message: "Error committing the transaction.".into(),
517+
});
518+
}
519+
} else {
520+
tx.rollback().await?;
521+
delete_statuses.push(DeleteStatus {
522+
id,
523+
status: Status::Error,
524+
message: "Failed to delete file.".into(),
525+
});
526+
}
527+
}
528+
}
529+
530+
let message = if deleted_count > 0 {
531+
format!("Successfully hard deleted {} papers.", deleted_count)
532+
} else {
533+
"No papers were deleted.".into()
534+
};
535+
536+
Ok(BackendResponse::ok(message, delete_statuses))
537+
}
538+
466539
/// Fetches all question papers that match one or more properties specified. `course_name` is compulsory.
467540
///
468541
/// # Request Query Parameters

backend/src/routing/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ pub fn get_router(env_vars: &EnvVars, db: Database) -> axum::Router {
3131

3232
axum::Router::new()
3333
.route("/unapproved", axum::routing::get(handlers::get_unapproved))
34+
.route("/trash", axum::routing::get(handlers::get_trash))
3435
.route("/details", axum::routing::get(handlers::get_paper_details))
3536
.route("/profile", axum::routing::get(handlers::profile))
3637
.route("/edit", axum::routing::post(handlers::edit))
3738
.route("/delete", axum::routing::post(handlers::delete))
39+
.route("/harddelete", axum::routing::post(handlers::hard_delete))
3840
.route("/similar", axum::routing::get(handlers::similar))
3941
.route_layer(axum::middleware::from_fn_with_state(
4042
state.clone(),

frontend/src/App.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
22
import SearchPage from "./pages/SearchPage";
33
import UploadPage from "./pages/UploadPage";
44
import OAuthPage from "./pages/OAuthPage";
5+
import AdminLayout from "./pages/AdminLayout";
56
import AdminDashboard from "./pages/AdminDashboard";
7+
import TrashPage from "./pages/TrashPage";
68
import { AuthProvider } from "./utils/auth";
79
import { Footer } from "./components/Common/Common";
810
import { Toaster } from "react-hot-toast";
@@ -25,10 +27,16 @@ function App() {
2527
path="/oauth"
2628
element={<OAuthPage />}
2729
/>
28-
<Route
29-
path="/admin"
30-
element={<AdminDashboard />}
31-
/>
30+
<Route path="/admin" element={<AdminLayout />}>
31+
<Route
32+
index
33+
element={<AdminDashboard />}
34+
/>
35+
<Route
36+
path="trash"
37+
element={<TrashPage />}
38+
/>
39+
</Route>
3240
</Routes>
3341
</AuthProvider>
3442
<Footer />

frontend/src/pages/AdminDashboard.tsx

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,7 @@ function AdminDashboard() {
214214

215215

216216
useEffect(() => {
217-
if (!auth.isAuthenticated) {
218-
window.location.assign(OAUTH_LOGIN_URL);
219-
} else {
220217
fetchUnapprovedPapers();
221-
}
222218
}, []);
223219

224220
const storeOcrDetails = async (paper: IAdminDashboardQP) => {
@@ -263,22 +259,8 @@ function AdminDashboard() {
263259
// ocrDetailsLoop();
264260
// }, [ocrRequests])
265261

266-
return auth.isAuthenticated ? (
267-
<div id="admin-dashboard">
268-
<Header
269-
title="Admin Dashboard"
270-
subtitle="Top secret documents - to be approved inside n-sided polygon shaped buildings only."
271-
link={{
272-
onClick: (e) => {
273-
e.preventDefault();
274-
auth.logout();
275-
},
276-
text: "Want to destroy the paper trail?",
277-
button_text: "Logout",
278-
icon: MdLogout,
279-
}}
280-
/>
281-
262+
return (
263+
<>
282264
<div className="dashboard-container">
283265
{awaitingResponse ? (
284266
<Spinner />
@@ -356,9 +338,7 @@ function AdminDashboard() {
356338
editPaper={(id) => setSearchParams({ edit: id.toString() })}
357339
/>
358340
)}
359-
</div>
360-
) : (
361-
<p>You are unauthenticated. This incident will be reported.</p>
341+
</>
362342
);
363343
}
364344

frontend/src/pages/AdminLayout.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useEffect } from "react";
2+
import { OAUTH_LOGIN_URL, useAuthContext } from "../utils/auth";
3+
import { Header } from "../components/Common/Common";
4+
import { MdLogout } from "react-icons/md";
5+
import { Outlet } from "react-router-dom";
6+
7+
function AdminLayout() {
8+
const auth = useAuthContext();
9+
10+
useEffect(() => {
11+
if (!auth.isAuthenticated) {
12+
window.location.assign(OAUTH_LOGIN_URL);
13+
}
14+
}, []);
15+
16+
return auth.isAuthenticated ? (
17+
<div id="admin-dashboard">
18+
<Header
19+
title="Admin Dashboard"
20+
subtitle="Top secret documents - to be approved inside n-sided polygon shaped buildings only."
21+
link={{
22+
onClick: (e) => {
23+
e.preventDefault();
24+
auth.logout();
25+
},
26+
text: "Want to destroy the paper trail?",
27+
button_text: "Logout",
28+
icon: MdLogout,
29+
}}
30+
/>
31+
32+
<Outlet />
33+
</div>
34+
) : (
35+
<p>You are unauthenticated. This incident will be reported.</p>
36+
);
37+
}
38+
39+
export default AdminLayout;

0 commit comments

Comments
 (0)