Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
37 changes: 34 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,39 @@ 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<bool, 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?;
Err(eyre!(
"Error: {} (> 1) papers were deleted. Rolling back.",
rows_affected
))
} else {
tx.commit().await?;
Ok(rows_affected == 1)
}
}

/// 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
59 changes: 54 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,44 @@ pub async fn delete(
}
}

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

/// 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<()> {
let mut deleted_count = 0;
for id in body.ids {
let paper = state.db.get_paper_by_id(id).await;
if let Ok(paper) = paper {
let file_path = state.env_vars.paths.get_path_from_slug(&paper.qp.filelink);
let _ = fs::remove_file(&file_path).await;
if let Ok(true) = state.db.hard_delete(id).await {
deleted_count += 1;
}
}
}

if deleted_count > 0 {
Ok(BackendResponse::ok(
format!("Successfully hard deleted {} papers.", deleted_count),
(),
))
} else {
Ok(BackendResponse::error(
"No papers were deleted. Either the papers do not exist, are library papers, or are already deleted.".into(),
StatusCode::BAD_REQUEST,
))
}
}

/// 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
5 changes: 5 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SearchPage from "./pages/SearchPage";
import UploadPage from "./pages/UploadPage";
import OAuthPage from "./pages/OAuthPage";
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 @@ -29,6 +30,10 @@ function App() {
path="/admin"
element={<AdminDashboard />}
/>
<Route
path="/admin/trash"
element={<TrashPage />}
/>
</Routes>
</AuthProvider>
<Footer />
Expand Down
202 changes: 202 additions & 0 deletions frontend/src/pages/TrashPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { OAUTH_LOGIN_URL, useAuthContext } from "../utils/auth";
import { makeRequest } from "../utils/backend";
import { IAdminDashboardQP } from "../types/question_paper";
import { Header } from "../components/Common/Common";

import "./styles/trash_page.scss";
import { MdLogout } from "react-icons/md";
import Spinner from "../components/Spinner/Spinner";
import toast from "react-hot-toast";
import { FaFilePdf, FaRegSquare, FaRegSquareCheck, FaTrash, FaX } from "react-icons/fa6";
import { useSearchParams } from "react-router-dom";
import { FaTrashRestore } from "react-icons/fa";

function TrashPage() {
const auth = useAuthContext();
const [trashPapers, setTrashPapers] = useState<
IAdminDashboardQP[]
>([]);
const [awaitingResponse, setAwaitingResponse] = useState<boolean>(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [allSelected, setAllSelected] = useState<boolean>(false);

const fetchTrashedPapers = async () => {
setAwaitingResponse(true);
const papers = await makeRequest("trash", "get", null, auth.jwt);

if (papers.status === "success") {
setTrashPapers(papers.data.slice(0, 40));
}

setAwaitingResponse(false);
};

const handleDelete = async () => {
const deleteInterval = 8;
let toastId: string | null = null;

const deleteTimeout = setTimeout(async () => {
const response = await makeRequest(
"harddelete",
"post",
{ ids: selectedIds },
auth.jwt,
);

if (response.status === "success") {
if (toastId !== null)
toast.success(`${response.message}`, {id: toastId});

setTrashPapers((papers) => {
return papers.filter((qp) => !selectedIds.includes(qp.id));
});
setSelectedIds([]);
setAllSelected(false);
} else {
if (toastId !== null)
toast.error(
`Delete error: ${response.message} (${response.status_code})`,
{ id: toastId },
);
}
}, deleteInterval * 1000);

const onAbort = () => {
clearTimeout(deleteTimeout);

if (toastId !== null) {
toast.success("Aborted paper deletion.", {id: toastId});
toastId = null;
setSelectedIds([]);
setAllSelected(false);
}
};

toastId = toast.loading(
<div className="delete-toast">
<p>
Deleting selected {selectedIds.length} papers
</p>
<button onClick={onAbort}><FaX />Cancel</button>
</div>,
{ duration: (deleteInterval + 1) * 1000 },
);
};

useEffect(() => {
if (allSelected) {
setSelectedIds(trashPapers.map(p => p.id));
} else {
setSelectedIds([]);
}
}, [allSelected])



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

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,
}}
/>

<div className="dashboard-container">
{awaitingResponse ? (
<Spinner />
) : (
<>
<div className="side-panel">
<p>
<b>Trashed papers</b>:{" "}
{trashPapers.length}
</p>
</div>
<div className="papers-panel">
{trashPapers.length === 0 ? (
<p>No papers in trash.</p>
) : (
<>
<div className="actions">
<div className="select-all">
<button className="btn" onClick={() => {
setAllSelected(prev => !prev);
}}>
{allSelected ? <FaRegSquareCheck size="1.2em" /> : <FaRegSquare size="1.2em" />}
</button>
Select All
</div>

<div>
<button className="btn icon-btn filled delete" onClick={handleDelete} disabled={selectedIds.length < 1}>
<FaTrash />
Delete Forever
</button>
</div>

{/* <div> // TODO
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you open an issue for this?

<button className="btn icon-btn filled">
<FaTrashRestore />
Restore
</button>
</div> */}
</div>
<div className="papers-list">
{trashPapers.map((paper) => (
<div key={paper.id}>
<div className="trash-paper">
<button className="btn" onClick={() => {
setSelectedIds(prev => {
if (prev.includes(paper.id)) return prev.filter(id => id != paper.id);
else return [...prev, paper.id];
})
}}>
{selectedIds.includes(paper.id) ? <FaRegSquareCheck size="1.2em" /> : <FaRegSquare size="1.2em" />}
</button>
<div className="course">
{paper.course_name}{" "}
{paper.course_code ? `(${paper.course_code})` : ""}
</div>
<div className="pills">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(future) can this paper list element be converted into a component? it is used in the search, admin approval dashboard, and now here. Even the upload page is a variant of it.

<div className="pill">{paper.year}</div>
<div className="pill">{paper.exam}</div>
<div className="pill">{paper.semester}</div>
{paper.note !== "" && <div className="pill">{paper.note}</div>}
</div>
<button className="btn">
<FaFilePdf size="1.2em" />
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
</>
)}
</div>
</div>
) : (
<p>You are unauthenticated. This incident will be reported.</p>
);
}

export default TrashPage;
Loading