diff --git a/client/web/src/pages/superadmin/settings/components/SettingsDialog.tsx b/client/web/src/pages/superadmin/settings/components/SettingsDialog.tsx
index 9b4a0e54..290271c0 100644
--- a/client/web/src/pages/superadmin/settings/components/SettingsDialog.tsx
+++ b/client/web/src/pages/superadmin/settings/components/SettingsDialog.tsx
@@ -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";
@@ -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 {
@@ -79,6 +81,7 @@ export function SettingsDialog({ trigger }: SettingsDialogProps) {
{activeTab === "applications" &&
}
{activeTab === "schedule" &&
}
+ {activeTab === "reset" &&
}
diff --git a/client/web/src/pages/superadmin/settings/tabs/ResetHackathonCard.tsx b/client/web/src/pages/superadmin/settings/tabs/ResetHackathonCard.tsx
new file mode 100644
index 00000000..d06feb23
--- /dev/null
+++ b/client/web/src/pages/superadmin/settings/tabs/ResetHackathonCard.tsx
@@ -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 (
+
+
+
+
+ Danger Zone
+
+
+ Irreversible actions that destroy data. Proceed with caution.
+
+
+
+
+
+
+ );
+}
diff --git a/client/web/vite.config.ts b/client/web/vite.config.ts
index 51f57859..ae721068 100644
--- a/client/web/vite.config.ts
+++ b/client/web/vite.config.ts
@@ -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) => {
@@ -40,7 +40,7 @@ export default defineConfig({
}
},
},
- '/v1': {
+ "/v1": {
target: apiTarget,
changeOrigin: true,
},
diff --git a/cmd/api/api.go b/cmd/api/api.go
index e48d1ed8..6178fe25 100644
--- a/cmd/api/api.go
+++ b/cmd/api/api.go
@@ -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) {
diff --git a/cmd/api/reset_hackathon.go b/cmd/api/reset_hackathon.go
new file mode 100644
index 00000000..d82b22fd
--- /dev/null
+++ b/cmd/api/reset_hackathon.go
@@ -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)
+ }
+}
diff --git a/cmd/api/reset_hackathon_test.go b/cmd/api/reset_hackathon_test.go
new file mode 100644
index 00000000..3b201880
--- /dev/null
+++ b/cmd/api/reset_hackathon_test.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/hackutd/portal/internal/store"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResetHackathon(t *testing.T) {
+ t.Run("should allow super admin to reset data", func(t *testing.T) {
+ app := newTestApplication(t)
+ app.gcsClient = nil // Ensure GCS client is nil to skip file deletion logic
+
+ payload := ResetHackathonPayload{
+ ResetApplications: true,
+ ResetScans: true,
+ ResetSchedule: true,
+ ResetSettings: true,
+ }
+
+ // Mock successful reset
+ app.store.Hackathon.(*store.MockHackathonStore).
+ On("Reset", true, true, true, true).
+ Return([]string{"resume1.pdf", "resume2.pdf"}, nil)
+
+ reqBody, _ := json.Marshal(payload)
+ req, _ := http.NewRequest(http.MethodPost, "/v1/superadmin/reset-hackathon", bytes.NewBuffer(reqBody))
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.resetHackathonHandler))
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var respBody struct {
+ Data ResetHackathonResponse `json:"data"`
+ }
+ err := json.Unmarshal(rr.Body.Bytes(), &respBody)
+ assert.NoError(t, err)
+ assert.True(t, respBody.Data.Success)
+ assert.Equal(t, 2, respBody.Data.ResumesDeleted)
+
+ app.store.Hackathon.(*store.MockHackathonStore).AssertExpectations(t)
+ })
+
+ t.Run("should return 500 when transaction fails", func(t *testing.T) {
+ app := newTestApplication(t)
+ app.gcsClient = nil
+
+ payload := ResetHackathonPayload{
+ ResetApplications: true,
+ }
+
+ // Simulate partial failure/rollback by returning error from store
+ app.store.Hackathon.(*store.MockHackathonStore).
+ On("Reset", true, false, false, false).
+ Return([]string(nil), errors.New("db transaction failed"))
+
+ reqBody, _ := json.Marshal(payload)
+ req, _ := http.NewRequest(http.MethodPost, "/v1/superadmin/reset-hackathon", bytes.NewBuffer(reqBody))
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.resetHackathonHandler))
+
+ assert.Equal(t, http.StatusInternalServerError, rr.Code)
+
+ app.store.Hackathon.(*store.MockHackathonStore).AssertExpectations(t)
+ })
+
+ t.Run("should forbid non-super-admin users", func(t *testing.T) {
+ app := newTestApplication(t)
+ payload := ResetHackathonPayload{ResetApplications: true}
+ reqBody, _ := json.Marshal(payload)
+
+ req, _ := http.NewRequest(http.MethodPost, "/v1/superadmin/reset-hackathon", bytes.NewBuffer(reqBody))
+ req = setUserContext(req, newAdminUser()) // Admin is not SuperAdmin
+
+ handler := app.RequireRoleMiddleware(store.RoleSuperAdmin)(http.HandlerFunc(app.resetHackathonHandler))
+ rr := executeRequest(req, handler)
+ assert.Equal(t, http.StatusForbidden, rr.Code)
+ })
+}
diff --git a/cmd/api/settings_test.go b/cmd/api/settings_test.go
index 6e5b1eac..9e4ae491 100644
--- a/cmd/api/settings_test.go
+++ b/cmd/api/settings_test.go
@@ -62,9 +62,25 @@ func TestUpdateShortAnswerQuestions(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr.Code)
mockSettings.AssertExpectations(t)
+ app.store.Hackathon.(*store.MockHackathonStore).AssertExpectations(t)
})
t.Run("should return 400 for duplicate question IDs", func(t *testing.T) {
+ body := `{"questions":[{"id":"q1","question":"A?","required":true,"display_order":0},{"id":"q1","question":"B?","required":false,"display_order":1}]}`
+
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.updateShortAnswerQuestions))
+ checkResponseCode(t, http.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("should return 500 when transaction fails", func(t *testing.T) {
+ app := newTestApplication(t)
+ app.gcsClient = nil
+
body := `{"questions":[{"id":"q1","question":"A?","required":true,"display_order":0},{"id":"q1","question":"B?","required":false,"display_order":1}]}`
req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body))
require.NoError(t, err)
diff --git a/docs/docs.go b/docs/docs.go
index 9ee77088..aaef7954 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -2227,6 +2227,89 @@ const docTemplate = `{
}
}
},
+ "/superadmin/reset-hackathon": {
+ "post": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Resets selected hackathon data (applications, scans, schedule, settings). Operations are performed in a single transaction.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "superadmin"
+ ],
+ "summary": "Reset hackathon data (Super Admin)",
+ "parameters": [
+ {
+ "description": "Reset options",
+ "name": "options",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.ResetHackathonPayload"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.ResetHackathonResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/superadmin/settings/admin-schedule-edit-toggle": {
"get": {
"security": [
@@ -3446,6 +3529,46 @@ const docTemplate = `{
}
}
},
+ "main.ResetHackathonPayload": {
+ "type": "object",
+ "properties": {
+ "reset_applications": {
+ "type": "boolean"
+ },
+ "reset_scans": {
+ "type": "boolean"
+ },
+ "reset_schedule": {
+ "type": "boolean"
+ },
+ "reset_settings": {
+ "type": "boolean"
+ }
+ }
+ },
+ "main.ResetHackathonResponse": {
+ "type": "object",
+ "properties": {
+ "reset_applications": {
+ "type": "boolean"
+ },
+ "reset_scans": {
+ "type": "boolean"
+ },
+ "reset_schedule": {
+ "type": "boolean"
+ },
+ "reset_settings": {
+ "type": "boolean"
+ },
+ "resumes_deleted": {
+ "type": "integer"
+ },
+ "success": {
+ "type": "boolean"
+ }
+ }
+ },
"main.ResumeDownloadURLResponse": {
"type": "object",
"properties": {
diff --git a/internal/store/hackathon.go b/internal/store/hackathon.go
new file mode 100644
index 00000000..72a5754e
--- /dev/null
+++ b/internal/store/hackathon.go
@@ -0,0 +1,77 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+)
+
+type HackathonStore struct {
+ db *sql.DB
+}
+
+// Reset resets the selected domains of hackathon data in a single transaction.
+// Returns a list of resume paths that should be deleted from storage if applications were reset.
+func (s *HackathonStore) Reset(ctx context.Context, resetApplications, resetScans, resetSchedule, resetSettings bool) ([]string, error) {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration*2) // Longer timeout for bulk operations
+ defer cancel()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+
+ var resumePaths []string
+
+ if resetApplications {
+ // Collect resume paths before truncation
+ rows, err := tx.QueryContext(ctx, "SELECT resume_path FROM applications WHERE resume_path IS NOT NULL")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var path string
+ if err := rows.Scan(&path); err != nil {
+ return nil, err
+ }
+ resumePaths = append(resumePaths, path)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ rows.Close()
+
+ if _, err := tx.ExecContext(ctx, "TRUNCATE TABLE applications CASCADE"); err != nil {
+ return nil, err
+ }
+ }
+
+ if resetScans {
+ if _, err := tx.ExecContext(ctx, "TRUNCATE TABLE scans"); err != nil {
+ return nil, err
+ }
+ }
+
+ if resetSchedule {
+ if _, err := tx.ExecContext(ctx, "TRUNCATE TABLE schedule"); err != nil {
+ return nil, err
+ }
+ }
+
+ if resetSettings {
+ if _, err := tx.ExecContext(ctx, "UPDATE settings SET value = '{}', updated_at = NOW() WHERE key = $1", SettingsKeyScanStats); err != nil {
+ return nil, err
+ }
+ if _, err := tx.ExecContext(ctx, "UPDATE settings SET value = '[]', updated_at = NOW() WHERE key = $1", SettingsKeyReviewAssignmentToggle); err != nil {
+ return nil, err
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+
+ return resumePaths, nil
+}
diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go
index c01e027e..9068792a 100644
--- a/internal/store/mock_store.go
+++ b/internal/store/mock_store.go
@@ -235,6 +235,19 @@ func (m *MockSettingsStore) GetScanStats(ctx context.Context) (map[string]int, e
return args.Get(0).(map[string]int), args.Error(1)
}
+// MockHackathonStore is a mock implementation of the Hackathon interface
+type MockHackathonStore struct {
+ mock.Mock
+}
+
+func (m *MockHackathonStore) Reset(ctx context.Context, resetApplications, resetScans, resetSchedule, resetSettings bool) ([]string, error) {
+ args := m.Called(resetApplications, resetScans, resetSchedule, resetSettings)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).([]string), args.Error(1)
+}
+
// MockApplicationReviewsStore is a mock implementation of the ApplicationReviews interface
type MockApplicationReviewsStore struct {
mock.Mock
@@ -358,6 +371,7 @@ func NewMockStore() Storage {
Users: &MockUsersStore{},
Application: &MockApplicationStore{},
Settings: &MockSettingsStore{},
+ Hackathon: &MockHackathonStore{},
ApplicationReviews: &MockApplicationReviewsStore{},
Scans: &MockScansStore{},
Schedule: &MockScheduleStore{},
diff --git a/internal/store/storage.go b/internal/store/storage.go
index e01a8965..dbde625c 100644
--- a/internal/store/storage.go
+++ b/internal/store/storage.go
@@ -54,6 +54,9 @@ type Storage struct {
UpdateScanTypes(ctx context.Context, scanTypes []ScanType) error
GetScanStats(ctx context.Context) (map[string]int, error)
}
+ Hackathon interface {
+ Reset(ctx context.Context, resetApplications, resetScans, resetSchedule, resetSettings bool) ([]string, error)
+ }
Scans interface {
Create(ctx context.Context, scan *Scan) error
GetByUserID(ctx context.Context, userID string) ([]Scan, error)
@@ -82,6 +85,7 @@ func NewStorage(db *sql.DB) Storage {
Users: &UsersStore{db: db},
Application: &ApplicationsStore{db: db},
Settings: &SettingsStore{db: db},
+ Hackathon: &HackathonStore{db: db},
ApplicationReviews: &ApplicationReviewsStore{db: db},
Scans: &ScansStore{db: db},
Schedule: &ScheduleStore{db: db},