Skip to content

Commit f77ab3f

Browse files
Merge pull request #231 from Sithumli/feat/external-application-delete
Feat: external application delete
2 parents 1eef40e + d9adc82 commit f77ab3f

4 files changed

Lines changed: 141 additions & 86 deletions

File tree

client/src/module/student/applications/MyApplicationsPage.tsx

Lines changed: 108 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11

22
import { Link } from "react-router";
33
import { motion } from "framer-motion";
4-
import { Briefcase, MapPin, Building2, ArrowUpRight, Clock, Search, ExternalLink, X } from "lucide-react";
5-
import { useQuery, useQueryClient } from "@tanstack/react-query";
4+
import { Briefcase, MapPin, Building2, ArrowUpRight, Clock, Search, ExternalLink, X, Trash2 } from "lucide-react";
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
66
import React, { useState, useMemo, useEffect, useCallback } from "react";
77
import api from "../../../lib/axios";
88
import { queryKeys } from "../../../lib/query-keys";
99
import type { Application } from "../../../lib/types";
1010
import { LoadingScreen } from "../../../components/LoadingScreen";
1111
import { SEO } from "../../../components/SEO";
12+
import { ConfirmDialog } from "../../../components/ui/ConfirmDialog";
1213
import toast from "@/components/ui/toast";
1314
interface ExternalApplication {
1415
id: number;
@@ -154,8 +155,10 @@ const ApplicationCard = React.memo(function ApplicationCard({
154155

155156
const ExternalApplicationCard = React.memo(function ExternalApplicationCard({
156157
app,
158+
onRemove,
157159
}: {
158160
app: ExternalApplication;
161+
onRemove: (id: number) => void;
159162
}) {
160163
return (
161164
<div className="group relative flex flex-col bg-white dark:bg-stone-900 p-5 rounded-md border border-stone-200 dark:border-white/10 hover:border-stone-400 dark:hover:border-white/30 transition-colors">
@@ -199,69 +202,42 @@ const ExternalApplicationCard = React.memo(function ExternalApplicationCard({
199202
year: "numeric",
200203
})}
201204
</span>
202-
{app.adminJob.applyLink && (
203-
<a
204-
href={app.adminJob.applyLink}
205-
target="_blank"
206-
rel="noopener noreferrer"
207-
className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-stone-900 dark:text-stone-50 hover:text-lime-600 dark:hover:text-lime-400 no-underline transition-colors"
208-
>
209-
View posting <ExternalLink className="w-3 h-3" />
210-
</a>
211-
)}
212-
</div>
213-
</div>
214-
);
215-
});
216-
217-
const PAGE_SIZE = 10;
218-
function WithdrawModal({
219-
open,
220-
onCancel,
221-
onConfirm,
222-
}: {
223-
open: boolean;
224-
onCancel: () => void;
225-
onConfirm: () => void;
226-
}) {
227-
if (!open) return null;
228-
return (
229-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
230-
<div className="bg-white dark:bg-stone-900 border border-stone-200 dark:border-white/10 rounded-md p-6 max-w-sm w-full mx-4 space-y-4">
231-
<h3 className="text-base font-bold text-stone-900 dark:text-stone-50">
232-
Withdraw Application?
233-
</h3>
234-
<p className="text-sm text-stone-500">
235-
Are you sure you want to withdraw this application? This action cannot be undone.
236-
</p>
237-
<div className="flex gap-3">
238-
<button
239-
onClick={onCancel}
240-
className="flex-1 px-4 py-2 rounded-md text-xs font-mono uppercase tracking-widest text-stone-600 dark:text-stone-400 border border-stone-200 dark:border-white/10 hover:border-stone-400 transition-colors bg-transparent cursor-pointer"
241-
>
242-
Cancel
243-
</button>
205+
<div className="flex items-center gap-2">
244206
<button
245-
onClick={onConfirm}
246-
className="flex-1 px-4 py-2 rounded-md text-xs font-mono uppercase tracking-widest text-white bg-red-500 hover:bg-red-600 transition-colors cursor-pointer border-0"
207+
onClick={() => onRemove(app.id)}
208+
className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-stone-500 hover:text-red-500 transition-colors bg-transparent border-0 cursor-pointer px-2 py-1"
247209
>
248-
Withdraw
210+
<Trash2 className="w-3 h-3" /> Remove
249211
</button>
212+
{app.adminJob.applyLink && (
213+
<a
214+
href={app.adminJob.applyLink}
215+
target="_blank"
216+
rel="noopener noreferrer"
217+
className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-stone-900 dark:text-stone-50 hover:text-lime-600 dark:hover:text-lime-400 no-underline transition-colors"
218+
>
219+
View posting <ExternalLink className="w-3 h-3" />
220+
</a>
221+
)}
250222
</div>
251223
</div>
252224
</div>
253225
);
254-
}
226+
});
255227

228+
const PAGE_SIZE = 10;
256229

230+
type PendingDelete =
231+
| { kind: "internal"; id: number }
232+
| { kind: "external"; id: number };
257233

258234
export default function MyApplicationsPage() {
259235
const queryClient = useQueryClient();
260236
const [search, setSearch] = useState("");
261237
const [debouncedSearch, setDebouncedSearch] = useState("");
262-
const [page, setPage] = useState(1);
263-
const [withdrawId, setWithdrawId] = useState<number | null>(null);
264-
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
238+
const [page, setPage] = useState(1);
239+
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
240+
265241
useEffect(() => {
266242
const t = setTimeout(() => setDebouncedSearch(search), 200);
267243
return () => clearTimeout(t);
@@ -302,38 +278,80 @@ export default function MyApplicationsPage() {
302278

303279
const totalAll = applications.length + externalApplications.length;
304280

305-
const handleWithdraw = useCallback(
306-
async (id: number) => {
307-
setWithdrawId(id);
308-
setShowWithdrawModal(true);
281+
const deleteMutation = useMutation({
282+
mutationFn: async (item: PendingDelete) => {
283+
if (item.kind === "internal") {
284+
await api.delete(`/student/applications/${item.id}`);
285+
} else {
286+
await api.delete(`/student/external-applications/${item.id}`);
287+
}
288+
return item;
309289
},
310-
[]
311-
);
290+
onSuccess: (item) => {
291+
if (item.kind === "internal") {
292+
queryClient.setQueryData<{
293+
applications: Application[];
294+
externalApplications: ExternalApplication[];
295+
}>(queryKeys.applications.mine(), (old) => {
296+
if (!old) return old;
297+
return {
298+
...old,
299+
applications: old.applications.map((a) =>
300+
a.id === item.id ? { ...a, status: "WITHDRAWN" as const } : a
301+
),
302+
};
303+
});
304+
toast.success("Application withdrawn successfully");
305+
} else {
306+
queryClient.setQueryData<{
307+
applications: Application[];
308+
externalApplications: ExternalApplication[];
309+
}>(queryKeys.applications.mine(), (old) => {
310+
if (!old) return old;
311+
return {
312+
...old,
313+
externalApplications: old.externalApplications.filter((a) => a.id !== item.id),
314+
};
315+
});
316+
toast.success("Application removed");
317+
}
318+
queryClient.invalidateQueries({ queryKey: queryKeys.applications.mine() });
319+
},
320+
onError: (_err, item) => {
321+
toast.error(
322+
item.kind === "internal"
323+
? "Failed to withdraw application"
324+
: "Failed to remove application"
325+
);
326+
},
327+
});
328+
329+
const handleWithdraw = useCallback((id: number) => {
330+
setPendingDelete({ kind: "internal", id });
331+
}, []);
332+
333+
const handleRemoveExternal = useCallback((id: number) => {
334+
setPendingDelete({ kind: "external", id });
335+
}, []);
336+
337+
const confirmDelete = useCallback(() => {
338+
if (!pendingDelete) return;
339+
const item = pendingDelete;
340+
setPendingDelete(null);
341+
deleteMutation.mutate(item);
342+
}, [pendingDelete, deleteMutation]);
343+
344+
const cancelDelete = useCallback(() => setPendingDelete(null), []);
345+
346+
const isInternalDelete = pendingDelete?.kind === "internal";
347+
const confirmTitle = isInternalDelete
348+
? "Withdraw application?"
349+
: "Remove tracked application?";
350+
const confirmDescription = isInternalDelete
351+
? "The recruiter will see this change. This action cannot be undone."
352+
: "This only removes it from your list. The job posting won't be affected.";
353+
const confirmLabel = isInternalDelete ? "Withdraw" : "Remove";
312354

313-
const confirmWithdraw = useCallback(async () => {
314-
if (!withdrawId) return;
315-
const idToWithdraw = withdrawId;
316-
setShowWithdrawModal(false);
317-
setWithdrawId(null);
318-
try {
319-
await api.delete(`/student/applications/${idToWithdraw}`);
320-
queryClient.setQueryData<{
321-
applications: Application[];
322-
externalApplications: ExternalApplication[];
323-
}>(queryKeys.applications.mine(), (old) => {
324-
if (!old) return old;
325-
return {
326-
...old,
327-
applications: old.applications.map((a) =>
328-
a.id === idToWithdraw ? { ...a, status: "WITHDRAWN" as const } : a
329-
),
330-
};
331-
});
332-
toast.success("Application withdrawn successfully");
333-
} catch {
334-
toast.error("Failed to withdraw application");
335-
}
336-
}, [withdrawId, queryClient]);
337355
if (isLoading) return <LoadingScreen />;
338356

339357

@@ -343,10 +361,14 @@ export default function MyApplicationsPage() {
343361
return (
344362
<div className="relative pb-16">
345363
<SEO title="My Applications" noIndex />
346-
<WithdrawModal
347-
open={showWithdrawModal}
348-
onCancel={() => setShowWithdrawModal(false)}
349-
onConfirm={confirmWithdraw}
364+
<ConfirmDialog
365+
open={pendingDelete !== null}
366+
title={confirmTitle}
367+
description={confirmDescription}
368+
confirmLabel={confirmLabel}
369+
cancelLabel="Cancel"
370+
onConfirm={confirmDelete}
371+
onCancel={cancelDelete}
350372
/>
351373

352374
{/* Header */}
@@ -465,7 +487,7 @@ export default function MyApplicationsPage() {
465487
{item.kind === "internal" ? (
466488
<ApplicationCard app={item.app} onWithdraw={handleWithdraw} />
467489
) : (
468-
<ExternalApplicationCard app={item.app} />
490+
<ExternalApplicationCard app={item.app} onRemove={handleRemoveExternal} />
469491
)}
470492
</motion.div>
471493
))}

server/src/module/student/student.controller.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,27 @@ export class StudentController {
158158
}
159159
}
160160

161+
async deleteExternalApplication(req: Request, res: Response) {
162+
try {
163+
if (!req.user) return res.status(401).json({ message: "Authentication required" });
164+
165+
const applicationId = parseInt(String(req.params["applicationId"]), 10);
166+
if (isNaN(applicationId) || applicationId <= 0) {
167+
return res.status(400).json({ message: "Invalid application ID" });
168+
}
169+
170+
const result = await this.studentService.deleteExternalApplication(applicationId, req.user.id);
171+
return res.status(200).json({ message: "External application removed", ...result });
172+
} catch (error) {
173+
if (error instanceof Error) {
174+
if (error.message === "External application not found") return res.status(404).json({ message: error.message });
175+
if (error.message === "Not authorized") return res.status(403).json({ message: error.message });
176+
}
177+
logger.error("Failed to delete external application", error);
178+
return res.status(500).json({ message: "Internal Server Error" });
179+
}
180+
}
181+
161182
async getExternalApplicationStatus(req: Request, res: Response) {
162183
try {
163184
if (!req.user) return res.status(401).json({ message: "Authentication required" });

server/src/module/student/student.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ studentRouter.delete("/applications/:applicationId", (req, res) => studentContro
2424
// External job applications
2525
studentRouter.post("/external-jobs/:adminJobId/apply", (req, res) => studentController.applyToExternalJob(req, res));
2626
studentRouter.get("/external-jobs/:adminJobId/status", (req, res) => studentController.getExternalApplicationStatus(req, res));
27+
studentRouter.delete("/external-applications/:applicationId", (req, res, next) => studentController.deleteExternalApplication(req, res, next));
2728

2829
// Round submissions
2930
studentRouter.get("/applications/:applicationId/rounds/:roundId", (req, res) => studentController.getRoundInfo(req, res));

server/src/module/student/student.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ export class StudentService {
153153
});
154154
}
155155

156+
async deleteExternalApplication(applicationId: number, studentId: number) {
157+
const application = await prisma.externalJobApplication.findUnique({
158+
where: { id: applicationId },
159+
});
160+
if (!application) throw new Error("External application not found");
161+
if (application.studentId !== studentId) throw new Error("Not authorized");
162+
163+
await prisma.externalJobApplication.delete({ where: { id: applicationId } });
164+
return { success: true };
165+
}
166+
156167
private async checkApplicationMilestone(studentId: number) {
157168
const [regular, external] = await Promise.all([
158169
prisma.application.count({ where: { studentId } }),

0 commit comments

Comments
 (0)