Skip to content

Commit 3612bce

Browse files
committed
feat: Add bulk job deletion API endpoint and integrate multi-select UI with client-side deletion logic into the history page.
1 parent 911587e commit 3612bce

File tree

2 files changed

+209
-35
lines changed

2 files changed

+209
-35
lines changed

src/pages/deleteJob.tsx

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,115 @@
11
import { rmSync } from "node:fs";
2-
import { Elysia } from "elysia";
2+
import { Elysia, t } from "elysia";
33
import { outputDir, uploadsDir } from "..";
44
import db from "../db/db";
55
import { WEBROOT } from "../helpers/env";
66
import { userService } from "./user";
77
import { Jobs } from "../db/types";
88

9-
export const deleteJob = new Elysia().use(userService).get(
10-
"/delete/:jobId",
11-
async ({ params, redirect, user }) => {
12-
const job = db
13-
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
14-
.as(Jobs)
15-
.get(user.id, params.jobId);
16-
17-
if (!job) {
18-
return redirect(`${WEBROOT}/results`, 302);
19-
}
20-
21-
// delete the directories
22-
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
23-
recursive: true,
24-
force: true,
25-
});
26-
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
27-
recursive: true,
28-
force: true,
29-
});
30-
31-
// delete the job
32-
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
33-
return redirect(`${WEBROOT}/history`, 302);
34-
},
35-
{
36-
auth: true,
37-
},
38-
);
9+
export const deleteJob = new Elysia()
10+
.use(userService)
11+
.get(
12+
"/delete/:jobId",
13+
async ({ params, redirect, user }) => {
14+
const job = db
15+
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
16+
.as(Jobs)
17+
.get(user.id, params.jobId);
18+
19+
if (!job) {
20+
return redirect(`${WEBROOT}/results`, 302);
21+
}
22+
23+
// delete the directories
24+
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
25+
recursive: true,
26+
force: true,
27+
});
28+
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
29+
recursive: true,
30+
force: true,
31+
});
32+
33+
// delete the job
34+
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
35+
return redirect(`${WEBROOT}/history`, 302);
36+
},
37+
{
38+
auth: true,
39+
},
40+
)
41+
.post(
42+
"/delete-multiple",
43+
async ({ body, user, set }) => {
44+
const { jobIds } = body;
45+
46+
if (!Array.isArray(jobIds) || jobIds.length === 0) {
47+
set.status = 400;
48+
return { success: false, message: "Invalid job IDs provided" };
49+
}
50+
51+
const results = {
52+
success: [] as string[],
53+
failed: [] as { jobId: string; error: string }[],
54+
};
55+
56+
// Process deletions sequentially for safety
57+
for (const jobId of jobIds) {
58+
try {
59+
const job = db
60+
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
61+
.as(Jobs)
62+
.get(user.id, jobId);
63+
64+
if (!job) {
65+
results.failed.push({
66+
jobId,
67+
error: "Job not found or unauthorized",
68+
});
69+
continue;
70+
}
71+
72+
// Delete the directories
73+
try {
74+
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
75+
recursive: true,
76+
force: true,
77+
});
78+
} catch (error) {
79+
console.error(`Failed to delete output directory for job ${jobId}:`, error);
80+
}
81+
82+
try {
83+
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
84+
recursive: true,
85+
force: true,
86+
});
87+
} catch (error) {
88+
console.error(`Failed to delete uploads directory for job ${jobId}:`, error);
89+
}
90+
91+
// Delete the job from database
92+
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
93+
results.success.push(jobId);
94+
} catch (error) {
95+
results.failed.push({
96+
jobId,
97+
error: error instanceof Error ? error.message : "Unknown error",
98+
});
99+
}
100+
}
101+
102+
return {
103+
success: results.failed.length === 0,
104+
deleted: results.success.length,
105+
failed: results.failed.length,
106+
details: results,
107+
};
108+
},
109+
{
110+
auth: true,
111+
body: t.Object({
112+
jobIds: t.Array(t.String()),
113+
}),
114+
},
115+
);

src/pages/history.tsx

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ export const history = new Elysia().use(userService).get(
4848
>
4949
<article class="article">
5050
<h1 class="mb-4 text-xl">Results</h1>
51+
<div id="delete-selected-container" class="mb-4 hidden">
52+
<button
53+
id="delete-selected-btn"
54+
class={`
55+
rounded bg-red-600 px-4 py-2 text-white transition-colors
56+
hover:bg-red-700
57+
`}
58+
>
59+
Delete Selected (<span id="selected-count">0</span>)
60+
</button>
61+
</div>
5162
<table
5263
class={`
5364
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
@@ -57,6 +68,19 @@ export const history = new Elysia().use(userService).get(
5768
>
5869
<thead>
5970
<tr>
71+
<th
72+
class={`
73+
px-2 py-2
74+
sm:px-4
75+
`}
76+
>
77+
<input
78+
type="checkbox"
79+
id="select-all"
80+
class="h-4 w-4 cursor-pointer"
81+
title="Select all"
82+
/>
83+
</th>
6084
<th
6185
class={`
6286
px-2 py-2
@@ -112,6 +136,13 @@ export const history = new Elysia().use(userService).get(
112136
{userJobs.map((job) => (
113137
<>
114138
<tr id={`job-row-${job.id}`}>
139+
<td>
140+
<input
141+
type="checkbox"
142+
class="job-checkbox h-4 w-4 cursor-pointer"
143+
data-job-id={job.id}
144+
/>
145+
</td>
115146
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
116147
<svg
117148
id={`arrow-${job.id}`}
@@ -155,7 +186,7 @@ export const history = new Elysia().use(userService).get(
155186
</td>
156187
</tr>
157188
<tr id={`details-${job.id}`} class="hidden">
158-
<td colspan="6">
189+
<td colspan="7">
159190
<div class="p-2 text-sm text-neutral-500">
160191
<div class="mb-1 font-semibold">Detailed File Information:</div>
161192
{job.files_detailed.map((file: Filename) => (
@@ -192,26 +223,92 @@ export const history = new Elysia().use(userService).get(
192223
<script>
193224
{`
194225
document.addEventListener('DOMContentLoaded', () => {
226+
// Expand/collapse job details
195227
const toggles = document.querySelectorAll('.job-details-toggle');
196228
toggles.forEach(toggle => {
197229
toggle.addEventListener('click', function() {
198230
const jobId = this.dataset.jobId;
199231
const detailsRow = document.getElementById(\`details-\${jobId}\`);
200-
// The arrow SVG itself has the ID arrow-\${jobId}
201232
const arrow = document.getElementById(\`arrow-\${jobId}\`);
202233
203234
if (detailsRow && arrow) {
204235
detailsRow.classList.toggle("hidden");
205236
if (detailsRow.classList.contains("hidden")) {
206-
// Right-facing arrow (collapsed)
207237
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />';
208238
} else {
209-
// Down-facing arrow (expanded)
210239
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />';
211240
}
212241
}
213242
});
214243
});
244+
245+
// Checkbox management
246+
const selectAllCheckbox = document.getElementById('select-all');
247+
const jobCheckboxes = document.querySelectorAll('.job-checkbox');
248+
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
249+
const deleteSelectedContainer = document.getElementById('delete-selected-container');
250+
const selectedCountSpan = document.getElementById('selected-count');
251+
252+
function updateDeleteButton() {
253+
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
254+
if (checkedBoxes.length > 0) {
255+
deleteSelectedContainer.classList.remove('hidden');
256+
selectedCountSpan.textContent = checkedBoxes.length;
257+
} else {
258+
deleteSelectedContainer.classList.add('hidden');
259+
}
260+
}
261+
262+
selectAllCheckbox?.addEventListener('change', function() {
263+
jobCheckboxes.forEach(checkbox => {
264+
checkbox.checked = this.checked;
265+
});
266+
updateDeleteButton();
267+
});
268+
269+
jobCheckboxes.forEach(checkbox => {
270+
checkbox.addEventListener('change', function() {
271+
const allChecked = Array.from(jobCheckboxes).every(cb => cb.checked);
272+
const someChecked = Array.from(jobCheckboxes).some(cb => cb.checked);
273+
if (selectAllCheckbox) {
274+
selectAllCheckbox.checked = allChecked;
275+
selectAllCheckbox.indeterminate = someChecked && !allChecked;
276+
}
277+
updateDeleteButton();
278+
});
279+
});
280+
281+
deleteSelectedBtn?.addEventListener('click', async function() {
282+
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
283+
const jobIds = checkedBoxes.map(cb => cb.dataset.jobId);
284+
285+
if (jobIds.length === 0) return;
286+
287+
const confirmed = confirm(\`Are you sure you want to delete \${jobIds.length} job(s)? This action cannot be undone.\`);
288+
if (!confirmed) return;
289+
290+
try {
291+
const response = await fetch('${WEBROOT}/delete-multiple', {
292+
method: 'POST',
293+
headers: {
294+
'Content-Type': 'application/json',
295+
},
296+
body: JSON.stringify({ jobIds }),
297+
});
298+
299+
const result = await response.json();
300+
301+
if (result.success || result.deleted > 0) {
302+
alert(\`Successfully deleted \${result.deleted} job(s).\${result.failed > 0 ? \` Failed to delete \${result.failed} job(s).\` : ''}\`);
303+
window.location.reload();
304+
} else {
305+
alert('Failed to delete jobs. Please try again.');
306+
}
307+
} catch (error) {
308+
console.error('Error deleting jobs:', error);
309+
alert('An error occurred while deleting jobs. Please try again.');
310+
}
311+
});
215312
});
216313
`}
217314
</script>

0 commit comments

Comments
 (0)