Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,7 @@ impl Database {
}

/// Returns the number of unapproved papers
pub async fn get_unapproved_papers_count(
&self,
) -> Result<i64, sqlx::Error> {
pub async fn get_unapproved_papers_count(&self) -> Result<i64, sqlx::Error> {
let count: (i64,) = sqlx::query_as(queries::GET_UNAPPROVED_COUNT)
.fetch_one(&self.connection)
.await?;
Expand Down Expand Up @@ -243,6 +241,40 @@ impl Database {
}
}

/// Gets all soft-deleted papers from the database
pub async fn get_soft_deleted_papers(&self) -> Result<Vec<AdminDashboardQP>, sqlx::Error> {
let query_sql = queries::get_get_soft_deleted_papers_query();
let papers: Vec<models::DBAdminDashboardQP> = 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<Transaction<'_, Postgres>, 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,
Expand Down
9 changes: 9 additions & 0 deletions backend/src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
83 changes: 78 additions & 5 deletions backend/src/routing/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterState>) -> HandlerReturn<Vec<AdminDashboardQP>> {
let papers: Vec<AdminDashboardQP> = state.db.get_soft_deleted_papers().await?;

let papers = papers
.iter()
.map(|paper| paper.clone().with_url(&state.env_vars))
.collect::<Result<Vec<qp::AdminDashboardQP>, 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<RouterState>,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -463,6 +474,68 @@ pub async fn delete(
}
}

#[derive(Deserialize)]
/// The request format for the hard delete endpoint
pub struct HardDeleteReq {
ids: Vec<i32>,
}

#[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<RouterState>,
Json(body): Json<HardDeleteReq>,
) -> HandlerReturn<Vec<DeleteStatus>> {
let mut delete_statuses = Vec::<DeleteStatus>::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
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,10 +27,16 @@ function App() {
path="/oauth"
element={<OAuthPage />}
/>
<Route
path="/admin"
element={<AdminDashboard />}
/>
<Route path="/admin" element={<AdminLayout />}>
<Route
index
element={<AdminDashboard />}
/>
<Route
path="trash"
element={<TrashPage />}
/>
</Route>
</Routes>
</AuthProvider>
<Footer />
Expand Down
26 changes: 3 additions & 23 deletions frontend/src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,7 @@ function AdminDashboard() {


useEffect(() => {
if (!auth.isAuthenticated) {
window.location.assign(OAUTH_LOGIN_URL);
} else {
fetchUnapprovedPapers();
}
}, []);

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

return auth.isAuthenticated ? (
<div id="admin-dashboard">
<Header
title="Admin Dashboard"
subtitle="Top secret documents - to be approved inside n-sided polygon shaped buildings only."
link={{
onClick: (e) => {
e.preventDefault();
auth.logout();
},
text: "Want to destroy the paper trail?",
button_text: "Logout",
icon: MdLogout,
}}
/>

return (
<>
<div className="dashboard-container">
{awaitingResponse ? (
<Spinner />
Expand Down Expand Up @@ -356,9 +338,7 @@ function AdminDashboard() {
editPaper={(id) => setSearchParams({ edit: id.toString() })}
/>
)}
</div>
) : (
<p>You are unauthenticated. This incident will be reported.</p>
</>
);
}

Expand Down
39 changes: 39 additions & 0 deletions frontend/src/pages/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect } from "react";
import { OAUTH_LOGIN_URL, useAuthContext } from "../utils/auth";
import { Header } from "../components/Common/Common";
import { MdLogout } from "react-icons/md";
import { Outlet } from "react-router-dom";

function AdminLayout() {
const auth = useAuthContext();

useEffect(() => {
if (!auth.isAuthenticated) {
window.location.assign(OAUTH_LOGIN_URL);
}
}, []);

return auth.isAuthenticated ? (
<div id="admin-dashboard">
<Header
title="Admin Dashboard"
subtitle="Top secret documents - to be approved inside n-sided polygon shaped buildings only."
link={{
onClick: (e) => {
e.preventDefault();
auth.logout();
},
text: "Want to destroy the paper trail?",
button_text: "Logout",
icon: MdLogout,
}}
/>

<Outlet />
</div>
) : (
<p>You are unauthenticated. This incident will be reported.</p>
);
}

export default AdminLayout;
Loading