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. + + + + + + + + + + + + Reset Hackathon Data + + + This action cannot be undone. This will permanently delete the + selected data from the database and remove associated files. + + + +
+
+ {[ + { + 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) => ( +
+ + setOptions((prev) => ({ + ...prev, + [item.id]: !!c, + })) + } + className="border-zinc-600 data-[state=checked]:bg-red-600 data-[state=checked]:border-red-600" + /> +
+ +

{item.desc}

+
+
+ ))} +
+ +
+ + 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" + /> +
+
+ + + + + +
+
+
+
+ ); +} 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},