Skip to content

Commit db4746e

Browse files
committed
feat(event): implement event deletion preview and permanent deletion functionality, including team size validation and proposal submission enhancements
1 parent 1611c86 commit db4746e

10 files changed

Lines changed: 492 additions & 26 deletions

File tree

backend/routes/event.route.js

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const { sendTeamInviteEmail } = require("../utils/mailer");
1010
const fetch = require("node-fetch");
1111
const { get: cGet, set: cSet, del: cDel, keys: cKeys, TTL, invalidateEvent } = require("../utils/cache");
1212
const { validateMoreDetails, normalizeRegistrationFields } = require("../utils/registration-fields");
13-
const { pickEventUpdateData } = require("../utils/event-update");
13+
const { pickEventUpdateData, validateTeamSize } = require("../utils/event-update");
1414
const {
1515
normalizeAssignedFacultyEmails,
1616
getCouncilOrganizerIdsForFaculty,
@@ -1060,6 +1060,10 @@ router.post(protected + "/create", authCheck, (req, res) => {
10601060
if (dates && dates.length) {
10611061
dates = dates.map((d) => new Date(d));
10621062
}
1063+
const teamCheck = validateTeamSize(min_ppt, ma_ppt);
1064+
if (!teamCheck.ok) {
1065+
return res.status(400).json({ error: true, message: teamCheck.message });
1066+
}
10631067
const normalizedFields = normalizeRegistrationFields(registration_fields);
10641068
prisma.events
10651069
.create({
@@ -1181,6 +1185,19 @@ router.post(
11811185
);
11821186
}
11831187

1188+
if (field.ma_ppt !== undefined || field.min_ppt !== undefined) {
1189+
const teamCheck = validateTeamSize(
1190+
field.min_ppt ?? existingEvent.min_ppt,
1191+
field.ma_ppt ?? existingEvent.ma_ppt,
1192+
);
1193+
if (!teamCheck.ok) {
1194+
return res.status(400).json({
1195+
error: true,
1196+
message: teamCheck.message,
1197+
});
1198+
}
1199+
}
1200+
11841201
if (req.user.role !== "COUNCIL") {
11851202
delete field.assigned_faculty_emails;
11861203
} else if (field.assigned_faculty_emails !== undefined) {
@@ -1292,6 +1309,25 @@ router.post(
12921309
"Selected faculty must be configured in council settings.",
12931310
});
12941311
}
1312+
1313+
const proposal = normalizeProposal(
1314+
existingEvent.proposal_document,
1315+
);
1316+
if (!proposal.document) {
1317+
return res.status(400).json({
1318+
error: true,
1319+
message:
1320+
"Build and save a proposal document before submitting.",
1321+
});
1322+
}
1323+
if (!allCouncilSignatoriesSigned(proposal)) {
1324+
return res.status(400).json({
1325+
error: true,
1326+
message:
1327+
"Every council signatory on the proposal must sign before submitting.",
1328+
});
1329+
}
1330+
12951331
field.assigned_faculty_emails = assigned;
12961332
field.comment = null;
12971333
}
@@ -2695,6 +2731,139 @@ router.get(protected + "/export-participants/:id", authCheck, async (req, res) =
26952731
}
26962732
});
26972733

2734+
/**
2735+
* GET /p/delete-preview/:id
2736+
* Counts of data that will be removed if the event is deleted.
2737+
*/
2738+
router.get(protected + "/delete-preview/:id", authCheck, async (req, res) => {
2739+
if (!req.user) return res.status(401).json({ error: true, message: "Unauthorized" });
2740+
const eventId = parseInt(req.params.id);
2741+
if (isNaN(eventId)) return res.status(400).json({ error: true, message: "Invalid id" });
2742+
2743+
const denied = await assertCouncilEventAccess(req.user, eventId);
2744+
if (denied) return res.status(denied.status).json({ error: true, message: denied.message });
2745+
2746+
try {
2747+
const event = await prisma.events.findUnique({
2748+
where: { id: eventId },
2749+
select: { name: true },
2750+
});
2751+
if (!event) {
2752+
return res.status(404).json({ error: true, message: "Event not found" });
2753+
}
2754+
2755+
const [
2756+
participants,
2757+
teams,
2758+
attended,
2759+
documents,
2760+
budgetItems,
2761+
announcements,
2762+
childEvents,
2763+
] = await Promise.all([
2764+
prisma.participant.count({ where: { event_id: eventId } }),
2765+
prisma.team.count({ where: { event_id: eventId } }),
2766+
prisma.participant.count({
2767+
where: { event_id: eventId, attended: true },
2768+
}),
2769+
prisma.eventDocument.count({ where: { event_id: eventId } }),
2770+
prisma.budgetItem.count({ where: { event_id: eventId } }),
2771+
prisma.announcement.count({ where: { event_id: eventId } }),
2772+
prisma.events.count({ where: { parent_id: eventId } }),
2773+
]);
2774+
2775+
return res.json({
2776+
error: false,
2777+
preview: {
2778+
name: event.name,
2779+
participants,
2780+
teams,
2781+
attended,
2782+
documents,
2783+
budgetItems,
2784+
announcements,
2785+
childEvents,
2786+
},
2787+
});
2788+
} catch (err) {
2789+
logger.error(err);
2790+
return res.status(500).json({
2791+
error: true,
2792+
message: "Error loading delete preview",
2793+
});
2794+
}
2795+
});
2796+
2797+
async function deleteEventTree(eventId, deletedIds = []) {
2798+
const children = await prisma.events.findMany({
2799+
where: { parent_id: eventId },
2800+
select: { id: true },
2801+
});
2802+
for (const child of children) {
2803+
await deleteEventTree(child.id, deletedIds);
2804+
}
2805+
await prisma.events.delete({ where: { id: eventId } });
2806+
deletedIds.push(eventId);
2807+
return deletedIds;
2808+
}
2809+
2810+
/**
2811+
* POST /p/delete/:id
2812+
* Permanently delete an event and all related data (participants, teams, etc.).
2813+
* Body: { confirm_name: string } must match the event name exactly.
2814+
*/
2815+
router.post(protected + "/delete/:id", authCheck, async (req, res) => {
2816+
if (!req.user) return res.status(401).json({ error: true, message: "Unauthorized" });
2817+
if (req.user.role !== "COUNCIL") {
2818+
return res.status(403).json({ error: true, message: "Forbidden" });
2819+
}
2820+
2821+
const eventId = parseInt(req.params.id);
2822+
if (isNaN(eventId)) return res.status(400).json({ error: true, message: "Invalid id" });
2823+
2824+
const denied = await assertCouncilEventAccess(req.user, eventId);
2825+
if (denied) return res.status(denied.status).json({ error: true, message: denied.message });
2826+
2827+
try {
2828+
const event = await prisma.events.findUnique({
2829+
where: { id: eventId },
2830+
select: { name: true },
2831+
});
2832+
if (!event) {
2833+
return res.status(404).json({ error: true, message: "Event not found" });
2834+
}
2835+
2836+
const confirmName =
2837+
req.body?.confirm_name != null
2838+
? String(req.body.confirm_name).trim()
2839+
: "";
2840+
if (confirmName !== event.name) {
2841+
return res.status(400).json({
2842+
error: true,
2843+
message:
2844+
"Type the exact event name to confirm permanent deletion.",
2845+
});
2846+
}
2847+
2848+
const deletedIds = await deleteEventTree(eventId);
2849+
for (const id of deletedIds) {
2850+
invalidateEvent(id, req.user.id);
2851+
}
2852+
2853+
return res.json({
2854+
error: false,
2855+
message: "Event deleted permanently.",
2856+
deleted_count: deletedIds.length,
2857+
});
2858+
} catch (err) {
2859+
logger.error(err);
2860+
return res.status(500).json({
2861+
error: true,
2862+
message: "Error deleting event",
2863+
});
2864+
}
2865+
});
2866+
26982867
router.get(
26992868
protected + "/attendance-report/:id",
27002869
authCheck,

backend/utils/event-update.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,22 @@ function pickEventUpdateData(raw) {
5555
return data;
5656
}
5757

58-
module.exports = { EVENT_UPDATE_KEYS, pickEventUpdateData };
58+
function validateTeamSize(min_ppt, ma_ppt) {
59+
const min = Number(min_ppt);
60+
const max = Number(ma_ppt);
61+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
62+
return { ok: false, message: "Team size must be valid numbers." };
63+
}
64+
if (min < 1 || max < 1) {
65+
return { ok: false, message: "Team size must be at least 1." };
66+
}
67+
if (max < min) {
68+
return {
69+
ok: false,
70+
message: "Max team size cannot be less than min team size.",
71+
};
72+
}
73+
return { ok: true };
74+
}
75+
76+
module.exports = { EVENT_UPDATE_KEYS, pickEventUpdateData, validateTeamSize };

backend/utils/proposal-document.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,21 @@ function allCouncilSignatoriesSigned(proposal) {
4848
),
4949
);
5050

51-
return signatories.every((sig) =>
52-
signedKeys.has(
51+
return signatories.every((sig) => {
52+
if (
53+
sig.signatureUrl &&
54+
String(sig.signatureUrl).trim()
55+
) {
56+
return true;
57+
}
58+
return signedKeys.has(
5359
signatoryKey({
5460
memberId: sig.memberId,
5561
name: sig.name,
62+
email: sig.email,
5663
}),
57-
),
58-
);
64+
);
65+
});
5966
}
6067

6168
function getSignaturePngUrl(signature) {

frontend/council-app/app/(dashboard)/approvals/page.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState, useEffect } from "react";
33
import Link from "next/link";
44
import { getNextAction, PIPELINE_STAGES, type EventData } from "@/lib/dummy-data";
55
import { transitionEventState, fetchCouncilProfile, type FacultyAdvisorRow } from "@/lib/api";
6+
import { submitProposal } from "@/lib/proposal";
67
import FacultyReviewerSelect from "@/components/FacultyReviewerSelect";
78
import { useData } from "@/contexts/DataContext";
89
import {
@@ -313,13 +314,15 @@ export default function ApprovalsPage() {
313314
if (loadingId !== null) return;
314315
setLoadingId(event.id);
315316
try {
316-
await transitionEventState(
317-
event.id,
318-
newState,
319-
assignedFaculty?.length
320-
? { assigned_faculty_emails: assignedFaculty }
321-
: undefined,
322-
);
317+
if (cta === "Submit Proposal" || cta === "Resubmit Proposal") {
318+
if (!assignedFaculty?.length) {
319+
showToast("Select at least one faculty advisor before submitting.");
320+
return;
321+
}
322+
await submitProposal(event.id, assignedFaculty);
323+
} else {
324+
await transitionEventState(event.id, newState);
325+
}
323326
await refreshEvents();
324327
const successMsg: Record<string, string> = {
325328
"Submit Proposal": `Proposal for "${event.name}" submitted to selected faculty!`,
@@ -328,8 +331,11 @@ export default function ApprovalsPage() {
328331
};
329332
showToast(successMsg[cta]);
330333
setSubmitModal(null);
331-
} catch {
332-
showToast(`Failed to perform "${cta}" — please try again.`);
334+
} catch (err: unknown) {
335+
const msg =
336+
(err as { response?: { data?: { message?: string } } })?.response?.data
337+
?.message;
338+
showToast(msg ?? `Failed to perform "${cta}" — please try again.`);
333339
} finally {
334340
setLoadingId(null);
335341
}

frontend/council-app/app/(dashboard)/controls/[id]/page.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ import {
1616
fetchEvent,
1717
type EventControlStats,
1818
} from "@/lib/api";
19+
import DeleteEventModal from "@/components/DeleteEventModal";
1920
import {
2021
ArrowLeft, Settings, Zap, Users, Ticket, CreditCard,
2122
BarChart2, Send, ClipboardList, Download, RefreshCcw,
2223
Eye, EyeOff, Play, Pause, Square, CheckCircle2, AlertCircle, Lock,
23-
ChevronRight, Edit2,
24+
ChevronRight, Edit2, Trash2,
2425
} from "lucide-react";
2526
import type { EventData } from "@/lib/dummy-data";
2627
import { useData } from "@/contexts/DataContext";
@@ -173,6 +174,7 @@ export default function EventControlsPage({ params }: { params: Promise<{ id: st
173174
const [saving, setSaving] = useState(false);
174175
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
175176
const [confirm, setConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
177+
const [showDeleteModal, setShowDeleteModal] = useState(false);
176178
const [transitioning, setTransitioning] = useState(false);
177179

178180
// Local settings state
@@ -265,6 +267,10 @@ export default function EventControlsPage({ params }: { params: Promise<{ id: st
265267
}
266268

267269
async function saveSettings() {
270+
if (maPpt < minPpt) {
271+
showToast("Max team size cannot be less than min team size.", false);
272+
return;
273+
}
268274
setSaving(true);
269275
try {
270276
await updateEventSettings(id, {
@@ -377,6 +383,19 @@ export default function EventControlsPage({ params }: { params: Promise<{ id: st
377383
</div>
378384
)}
379385

386+
{showDeleteModal && (
387+
<DeleteEventModal
388+
eventId={id}
389+
onClose={() => setShowDeleteModal(false)}
390+
onDeleted={async () => {
391+
setShowDeleteModal(false);
392+
await refreshEvents();
393+
showToast("Event deleted permanently.");
394+
router.push("/");
395+
}}
396+
/>
397+
)}
398+
380399
{/* ── Header ── */}
381400
<div className="flex items-center gap-3 mb-5">
382401
<button type="button" onClick={() => router.back()}
@@ -499,6 +518,11 @@ export default function EventControlsPage({ params }: { params: Promise<{ id: st
499518
value={maPpt} onChange={(v) => setMaPpt(v ?? 1)} min={1} />
500519
<NumberInput label="Min team size" description="Minimum for submission"
501520
value={minPpt} onChange={(v) => setMinPpt(v ?? 1)} min={1} />
521+
{maPpt < minPpt && (
522+
<p className="text-red-500 text-xs font-fira -mt-2">
523+
Max team size cannot be less than min team size.
524+
</p>
525+
)}
502526
<Toggle label="Somaiya Students Only" description="Restrict to @somaiya.edu"
503527
checked={somaiyaOnly} onChange={setSomaiyaOnly} />
504528
<Toggle label="More Details Form" description="Show custom fields during registration"
@@ -565,6 +589,22 @@ export default function EventControlsPage({ params }: { params: Promise<{ id: st
565589
{saving ? "Saving…" : "Save All Settings"}
566590
</button>
567591

592+
{/* Danger zone */}
593+
<SectionCard title="Danger Zone" icon={<Trash2 size={16} className="text-red-500" />}>
594+
<p className="text-muted-tx text-sm font-fira mb-4">
595+
Permanently remove this event and all participant registrations,
596+
attendance records, teams, documents, budget entries, and announcements.
597+
</p>
598+
<button
599+
type="button"
600+
onClick={() => setShowDeleteModal(true)}
601+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-red-500/40 bg-red-500/10 hover:bg-red-500/20 text-red-500 text-sm font-fira font-semibold transition-colors"
602+
>
603+
<Trash2 size={15} />
604+
Delete Event Permanently
605+
</button>
606+
</SectionCard>
607+
568608
</div>
569609
</div>
570610

0 commit comments

Comments
 (0)