Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { CalendarRange, UsersRound } from "lucide-react";
import { AlertTriangle, CalendarRange, UsersRound } from "lucide-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
Expand All @@ -17,13 +17,15 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/shared/lib/utils";

import ApplicationsTab from "../tabs/ApplicationsTab";
import { ResetHackathonCard } from "../tabs/ResetHackathonCard";
import ScheduleTab from "../tabs/ScheduleTab";

type SettingsTab = "applications" | "schedule";
type SettingsTab = "set-admin" | "applications" | "schedule" | "reset";

const settingsTabs = [
{ id: "applications" as const, label: "Applications", icon: UsersRound },
{ id: "schedule" as const, label: "Schedule", icon: CalendarRange },
{ id: "reset" as const, label: "Danger Zone", icon: AlertTriangle },
];

interface SettingsDialogProps {
Expand Down Expand Up @@ -79,6 +81,7 @@ export function SettingsDialog({ trigger }: SettingsDialogProps) {
<div className="p-8">
{activeTab === "applications" && <ApplicationsTab />}
{activeTab === "schedule" && <ScheduleTab />}
{activeTab === "reset" && <ResetHackathonCard />}
</div>
</ScrollArea>

Expand Down
201 changes: 201 additions & 0 deletions client/web/src/pages/superadmin/settings/tabs/ResetHackathonCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { postRequest } from "@/shared/lib/api";

export function ResetHackathonCard() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [options, setOptions] = useState({
reset_applications: false,
reset_scans: false,
reset_schedule: false,
reset_settings: false,
});

const handleReset = async () => {
if (confirmText !== "RESET HACKATHON") return;

// Ensure at least one option is selected
if (!Object.values(options).some(Boolean)) {
toast.error("Please select at least one item to reset");
return;
}

setLoading(true);
try {
const res = await postRequest<{ success: boolean }>(
"/superadmin/reset-hackathon",
options,
);

if (res.error) {
toast.error(res.error);
return;
}

toast.success("Hackathon data reset successfully");
setOpen(false);
setConfirmText("");
setOptions({
reset_applications: false,
reset_scans: false,
reset_schedule: false,
reset_settings: false,
});
} catch (err) {
toast.error(
"An unexpected error occurred" +
(err instanceof Error ? `: ${err.message}` : ""),
);
} finally {
setLoading(false);
}
};

return (
<Card className="bg-zinc-900 border-zinc-800 border-0 rounded-md">
<CardHeader>
<CardTitle className="text-red-500 flex items-center gap-2">
<AlertTriangle className="size-5" />
Danger Zone
</CardTitle>
<CardDescription className="text-zinc-400">
Irreversible actions that destroy data. Proceed with caution.
</CardDescription>
</CardHeader>
<CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
<Trash2 className="mr-2 size-4" />
Reset Hackathon Data
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md bg-zinc-900 border-zinc-800 text-zinc-100">
<DialogHeader>
<DialogTitle className="text-red-500 flex items-center gap-2">
<AlertTriangle className="size-5" />
Reset Hackathon Data
</DialogTitle>
<DialogDescription className="text-zinc-400">
This action cannot be undone. This will permanently delete the
selected data from the database and remove associated files.
</DialogDescription>
</DialogHeader>

<div className="py-4 space-y-4">
<div className="space-y-3 border border-zinc-800 rounded-md p-4 bg-zinc-950/50">
{[
{
id: "reset_applications",
label: "Applications",
desc: "Deletes all hacker applications, reviews, and resume files.",
},
{
id: "reset_scans",
label: "Scans",
desc: "Deletes all check-in, meal, and event scan records.",
},
{
id: "reset_schedule",
label: "Schedule",
desc: "Deletes all schedule events.",
},
{
id: "reset_settings",
label: "Settings Stats",
desc: "Resets scan stats and review assignment toggles.",
},
].map((item) => (
<div key={item.id} className="flex items-start space-x-3">
<Checkbox
id={item.id}
checked={options[item.id as keyof typeof options]}
onCheckedChange={(c) =>
setOptions((prev) => ({
...prev,
[item.id]: !!c,
}))
}
className="border-zinc-600 data-[state=checked]:bg-red-600 data-[state=checked]:border-red-600"
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor={item.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-zinc-200"
>
{item.label}
</Label>
<p className="text-xs text-zinc-500">{item.desc}</p>
</div>
</div>
))}
</div>

<div className="space-y-2">
<Label htmlFor="confirm" className="text-zinc-200">
Type <strong className="text-red-500">RESET HACKATHON</strong>{" "}
to confirm
</Label>
<Input
id="confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="RESET HACKATHON"
className="bg-zinc-950 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:ring-red-500/20 focus-visible:border-red-500"
/>
</div>
</div>

<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
className="bg-transparent border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleReset}
disabled={
loading ||
confirmText !== "RESET HACKATHON" ||
!Object.values(options).some(Boolean)
}
className="bg-red-600 hover:bg-red-700 text-white border-0"
>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
Reset Data
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}
6 changes: 3 additions & 3 deletions client/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import path from "path";
import { defineConfig } from "vite";

// https://vite.dev/config/
const apiTarget = process.env.API_PROXY_TARGET || 'http://localhost:8080';
const apiTarget = process.env.API_PROXY_TARGET || "http://localhost:8080";

export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 3000,
proxy: {
'/auth': {
"/auth": {
target: apiTarget,
changeOrigin: true,
bypass: (req) => {
Expand Down Expand Up @@ -40,7 +40,7 @@ export default defineConfig({
}
},
},
'/v1': {
"/v1": {
target: apiTarget,
changeOrigin: true,
},
Expand Down
1 change: 1 addition & 0 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func (app *application) mount() http.Handler {
r.Use(app.RequireRoleMiddleware(store.RoleSuperAdmin))
// Super admin routes
r.Route("/superadmin", func(r chi.Router) {
r.Post("/reset-hackathon", app.resetHackathonHandler)

// Configs
r.Route("/settings", func(r chi.Router) {
Expand Down
87 changes: 87 additions & 0 deletions cmd/api/reset_hackathon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"context"
"errors"
"net/http"
)

type ResetHackathonPayload struct {
ResetApplications bool `json:"reset_applications"`
ResetScans bool `json:"reset_scans"`
ResetSchedule bool `json:"reset_schedule"`
ResetSettings bool `json:"reset_settings"`
}

type ResetHackathonResponse struct {
Success bool `json:"success"`
ResetApplications bool `json:"reset_applications"`
ResetScans bool `json:"reset_scans"`
ResetSchedule bool `json:"reset_schedule"`
ResetSettings bool `json:"reset_settings"`
ResumesDeleted int `json:"resumes_deleted"`
}

// resetHackathonHandler resets hackathon data based on options
//
// @Summary Reset hackathon data (Super Admin)
// @Description Resets selected hackathon data (applications, scans, schedule, settings). Operations are performed in a single transaction.
// @Tags superadmin
// @Accept json
// @Produce json
// @Param options body ResetHackathonPayload true "Reset options"
// @Success 200 {object} ResetHackathonResponse
// @Failure 400 {object} object{error=string}
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/reset-hackathon [post]
func (app *application) resetHackathonHandler(w http.ResponseWriter, r *http.Request) {
var req ResetHackathonPayload
if err := readJSON(w, r, &req); err != nil {
app.badRequestResponse(w, r, err)
return
}

if err := Validate.Struct(req); err != nil {
app.badRequestResponse(w, r, err)
return
}

user := getUserFromContext(r.Context())
if user == nil {
app.internalServerError(w, r, errors.New("user not in context"))
return
}

resumePaths, err := app.store.Hackathon.Reset(r.Context(), req.ResetApplications, req.ResetScans, req.ResetSchedule, req.ResetSettings)
if err != nil {
app.internalServerError(w, r, err)
return
}

// Best-effort cleanup of resumes from GCS
if len(resumePaths) > 0 && app.gcsClient != nil {
go func(paths []string) {
for _, path := range paths {
_ = app.gcsClient.DeleteObject(context.Background(), path)
}
}(resumePaths)
}

app.logger.Infow("hackathon data reset", "user_id", user.ID, "user_email", user.Email, "reset_apps", req.ResetApplications, "reset_scans", req.ResetScans, "reset_schedule", req.ResetSchedule, "reset_settings", req.ResetSettings, "resumes_deleted_count", len(resumePaths))

response := ResetHackathonResponse{
Success: true,
ResetApplications: req.ResetApplications,
ResetScans: req.ResetScans,
ResetSchedule: req.ResetSchedule,
ResetSettings: req.ResetSettings,
ResumesDeleted: len(resumePaths),
}

if err := app.jsonResponse(w, http.StatusOK, response); err != nil {
app.internalServerError(w, r, err)
}
}
Loading
Loading