diff --git a/claude.md b/claude.md index 205eefac..03cfded3 100644 --- a/claude.md +++ b/claude.md @@ -163,7 +163,7 @@ Runs on every push/PR to `main` (`.github/workflows/audit.yaml`): ## API Routes **Auth:** `GET /v1/auth/check-email`, `GET /v1/auth/me` -**Hacker:** `GET|PATCH /v1/applications/me`, `POST /v1/applications/me/submit` +**Hacker:** `GET|PATCH /v1/applications/me`, `POST /v1/applications/me/submit`, `GET /v1/points-name` **Admin:** `GET /v1/admin/applications`, `GET /v1/admin/applications/stats`, `GET /v1/admin/applications/{id}`, `GET /v1/admin/applications/{id}/notes`, `GET /v1/admin/reviews/pending`, `GET /v1/admin/reviews/completed`, `GET /v1/admin/reviews/next`, `PUT /v1/admin/reviews/{id}`, `GET /v1/admin/scans/types`, `POST /v1/admin/scans`, `GET /v1/admin/scans/user/{userID}`, `GET /v1/admin/scans/stats`, `POST /v1/admin/scans/rebalance-stats` -**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr`, `GET /v1/superadmin/walk-ins`, `POST /v1/superadmin/walk-ins/promote` +**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/settings/points-name`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr`, `GET /v1/superadmin/walk-ins`, `POST /v1/superadmin/walk-ins/promote` **Infra (Basic Auth):** `GET /v1/health`, `GET /v1/debug/vars`, `GET /v1/swagger/*` diff --git a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx index 01f0dccd..b8e71186 100644 --- a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx +++ b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { SearchBar } from "@/pages/admin/_shared"; +import { usePointsNameStore } from "@/shared/stores"; import { ApplicationDetailPanel } from "./components/ApplicationDetailPanel"; import { ApplicationsTable } from "./components/ApplicationsTable"; @@ -31,6 +32,7 @@ export default function AllApplicantsPage() { const statsLoading = useApplicationsStore((s) => s.statsLoading); const fetchApplications = useApplicationsStore((s) => s.fetchApplications); const fetchStats = useApplicationsStore((s) => s.fetchStats); + const fetchPointsName = usePointsNameStore((s) => s.fetchPointsName); const [searchInput, setSearchInput] = useState(currentSearch); const [selectedApplicationId, setSelectedApplicationId] = useState< @@ -46,8 +48,9 @@ export default function AllApplicantsPage() { const controller = new AbortController(); fetchApplications(undefined, controller.signal); fetchStats(controller.signal); + fetchPointsName(controller.signal); return () => controller.abort(); - }, [fetchApplications, fetchStats]); + }, [fetchApplications, fetchStats, fetchPointsName]); const isFirstRender = useRef(true); useEffect(() => { diff --git a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx index 423ecc0f..b25d75ac 100644 --- a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx +++ b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx @@ -12,6 +12,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { errorAlert } from "@/shared/lib/api"; +import { usePointsNameStore } from "@/shared/stores"; import type { Application } from "@/types"; import { fetchApplicationResumeURL } from "../api"; @@ -34,6 +35,7 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ onGrade, }: ApplicationDetailPanelProps) { const [isOpeningResume, setIsOpeningResume] = useState(false); + const pointsName = usePointsNameStore((s) => s.pointsName); const handleViewResume = useCallback(async () => { if (!application || !application.resume_path || isOpeningResume) { @@ -76,6 +78,9 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ {application.status} + + {application.points ?? 0} {pointsName} + ) : null} diff --git a/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx b/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx index f7067016..8ad5ca27 100644 --- a/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx +++ b/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx @@ -11,6 +11,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { usePointsNameStore } from "@/shared/stores"; import type { ApplicationListItem } from "../types"; import { formatName, getStatusColor } from "../utils"; @@ -28,6 +29,8 @@ export const ApplicationsTable = memo(function ApplicationsTable({ selectedId, onSelectApplication, }: ApplicationsTableProps) { + const pointsName = usePointsNameStore((s) => s.pointsName); + return (
{loading && ( @@ -51,12 +54,13 @@ export const ApplicationsTable = memo(function ApplicationsTable({ Created Updated AI Percent + {pointsName} {applications.length === 0 ? ( - + No applications found @@ -107,6 +111,7 @@ export const ApplicationsTable = memo(function ApplicationsTable({ {app.ai_percent != null ? `${app.ai_percent}%` : "-"} + {app.points} )) )} diff --git a/client/web/src/pages/admin/all-applicants/types.ts b/client/web/src/pages/admin/all-applicants/types.ts index 50f6b836..fe19a7aa 100644 --- a/client/web/src/pages/admin/all-applicants/types.ts +++ b/client/web/src/pages/admin/all-applicants/types.ts @@ -30,6 +30,7 @@ export interface ApplicationListItem { reviews_assigned: number; reviews_completed: number; has_resume: boolean; + points: number; } export interface ApplicationListResult { diff --git a/client/web/src/pages/admin/scans/ScansPage.tsx b/client/web/src/pages/admin/scans/ScansPage.tsx index b7db5e03..b27b9f53 100644 --- a/client/web/src/pages/admin/scans/ScansPage.tsx +++ b/client/web/src/pages/admin/scans/ScansPage.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { useUserStore } from "@/shared/stores/user"; +import { usePointsNameStore, useUserStore } from "@/shared/stores"; import { ScannerDialog } from "./components/ScannerDialog"; import { ScanStatsCards } from "./components/ScanStatsCards"; @@ -24,6 +24,7 @@ export default function ScansPage() { rebalanceStats, setActiveScanType, } = useScansStore(); + const fetchPointsName = usePointsNameStore((s) => s.fetchPointsName); const isSuperAdmin = user?.role === "super_admin"; @@ -31,12 +32,13 @@ export default function ScansPage() { const controller = new AbortController(); fetchTypes(controller.signal); fetchStats(controller.signal); + fetchPointsName(controller.signal); return () => { controller.abort(); // Reset active scan type so dialog doesn't reopen on navigate back setActiveScanType(null); }; - }, [fetchTypes, fetchStats, setActiveScanType]); + }, [fetchTypes, fetchStats, fetchPointsName, setActiveScanType]); if (typesLoading && scanTypes.length === 0) { return ( diff --git a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx index 37bf6747..c2c6f7a4 100644 --- a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx +++ b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx @@ -2,6 +2,7 @@ import { DoorOpen, Gift, MoreHorizontal, + ShoppingBag, UserCheck, Utensils, } from "lucide-react"; @@ -19,6 +20,7 @@ const categoryIcons: Record = { check_in: UserCheck, meal: Utensils, swag: Gift, + shop: ShoppingBag, other: MoreHorizontal, walk_in: DoorOpen, }; diff --git a/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx b/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx index 9ac11bba..f3b441af 100644 --- a/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx +++ b/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx @@ -37,6 +37,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { usePointsNameStore } from "@/shared/stores"; import type { ScanStat, ScanType, ScanTypeCategory } from "../types"; import { @@ -72,10 +73,12 @@ export function ScanTypesTable({ }: ScanTypesTableProps) { const [editingIndex, setEditingIndex] = useState(null); const [editDisplayName, setEditDisplayName] = useState(""); + const [editPoints, setEditPoints] = useState("0"); const [deleteIndex, setDeleteIndex] = useState(null); const [pendingNew, setPendingNew] = useState(null); const [rebalanceOpen, setRebalanceOpen] = useState(false); const editRowRef = useRef(null); + const pointsName = usePointsNameStore((s) => s.pointsName); // When there's a pending new row, append it so it renders in the table const effectiveTypes = pendingNew ? [...scanTypes, pendingNew] : scanTypes; @@ -90,6 +93,7 @@ export function ScanTypesTable({ setEditingIndex(index); if (scanTypes[index]) { setEditDisplayName(scanTypes[index].display_name); + setEditPoints(String(scanTypes[index].points ?? 0)); } }; @@ -97,6 +101,8 @@ export function ScanTypesTable({ if (editingIndex === null) return; const trimmed = editDisplayName.trim(); + const parsedPoints = Number.parseInt(editPoints, 10); + const points = Number.isNaN(parsedPoints) ? 0 : parsedPoints; // Pending new row — save only if user typed something, otherwise no-op if (pendingNew) { @@ -105,6 +111,7 @@ export function ScanTypesTable({ ...pendingNew, display_name: trimmed, name: toSnakeCase(trimmed), + points, }; const updated = [...scanTypes, newType]; @@ -124,11 +131,20 @@ export function ScanTypesTable({ if (!current) return; // No change — skip save - if (trimmed === current.display_name) return; + const nameChanged = trimmed !== current.display_name; + if (!nameChanged && points === current.points) return; + // Only re-derive `name` when the display name actually changed — it's the + // key historical scans and scan_stats are keyed by, so a points-only edit + // must not rename the scan type. const updated = scanTypes.map((st, i) => i === editingIndex - ? { ...st, display_name: trimmed, name: toSnakeCase(trimmed) } + ? { + ...st, + display_name: trimmed, + name: nameChanged ? toSnakeCase(trimmed) : st.name, + points, + } : st, ); @@ -139,7 +155,14 @@ export function ScanTypesTable({ } onSave(updated); - }, [editingIndex, editDisplayName, scanTypes, pendingNew, onSave]); + }, [ + editingIndex, + editDisplayName, + editPoints, + scanTypes, + pendingNew, + onSave, + ]); // Ref to avoid stale closures in event listeners const saveDisplayNameRef = useRef(saveDisplayName); @@ -197,10 +220,12 @@ export function ScanTypesTable({ display_name: "", category: "other", is_active: true, + points: 0, }; setPendingNew(newType); setEditingIndex(scanTypes.length); setEditDisplayName(""); + setEditPoints("0"); }; const handleDelete = (index: number) => { @@ -295,6 +320,7 @@ export function ScanTypesTable({ Action Name Category + {pointsName} Scans {isSuperAdmin && Active} @@ -375,6 +401,29 @@ export function ScanTypesTable({ })}
+ + setEditPoints(e.target.value)} + onBlur={(e) => { + if ( + editRowRef.current?.contains( + e.relatedTarget as Node, + ) + ) + return; + saveDisplayName(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + closeEditing(); + } + }} + className="h-8 w-20 text-sm font-light shadow-none bg-transparent pl-2 rounded-sm focus-visible:ring-1" + /> + {count}
@@ -461,6 +510,9 @@ export function ScanTypesTable({
+ + {scanType.points ?? 0} + {count} {isSuperAdmin && ( diff --git a/client/web/src/pages/admin/scans/components/ScannerDialog.tsx b/client/web/src/pages/admin/scans/components/ScannerDialog.tsx index bc291c38..d732e712 100644 --- a/client/web/src/pages/admin/scans/components/ScannerDialog.tsx +++ b/client/web/src/pages/admin/scans/components/ScannerDialog.tsx @@ -10,6 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { usePointsNameStore } from "@/shared/stores"; import { useScansStore } from "../store"; import { useQrScanner } from "./useQrScanner"; @@ -23,6 +24,7 @@ export function ScannerDialog() { performScan, clearLastResult, } = useScansStore(); + const pointsName = usePointsNameStore((s) => s.pointsName); const handleScan = useCallback( (decodedText: string) => { @@ -103,6 +105,21 @@ export function ScannerDialog() { )}

{lastScanResult.message}

+ {lastScanResult.success && + (lastScanResult.scan?.points ?? 0) !== 0 && ( +

+ {(lastScanResult.scan?.points ?? 0) > 0 + ? `+${lastScanResult.scan?.points}` + : `−${Math.abs(lastScanResult.scan?.points ?? 0)}`}{" "} + {pointsName} +

+ )} + {lastScanResult.success && + lastScanResult.scan?.balance !== undefined && ( +

+ Balance: {lastScanResult.scan.balance} {pointsName} +

+ )} + )} + + + + ); +} diff --git a/client/web/src/pages/superadmin/settings/types.ts b/client/web/src/pages/superadmin/settings/types.ts index 1983b25b..d470ea8d 100644 --- a/client/web/src/pages/superadmin/settings/types.ts +++ b/client/web/src/pages/superadmin/settings/types.ts @@ -26,3 +26,7 @@ export interface MealGroupStatsResult { export interface HackerPackURLResult { url: string; } + +export interface PointsNameResult { + name: string; +} diff --git a/client/web/src/shared/stores/index.ts b/client/web/src/shared/stores/index.ts index d37b6cc1..19212d90 100644 --- a/client/web/src/shared/stores/index.ts +++ b/client/web/src/shared/stores/index.ts @@ -1,2 +1,4 @@ +export type { PointsNameState } from "./pointsName"; +export { usePointsNameStore } from "./pointsName"; export type { AuthError, UserState } from "./user"; export { useUserStore } from "./user"; diff --git a/client/web/src/shared/stores/pointsName.ts b/client/web/src/shared/stores/pointsName.ts new file mode 100644 index 00000000..4db61ac6 --- /dev/null +++ b/client/web/src/shared/stores/pointsName.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +import { getRequest } from "@/shared/lib/api"; + +interface PointsNameResponse { + name: string; +} + +// Display name of the points system (e.g. "HackBucks"), configured by super +// admins and shown to hackers and admins alike. +export interface PointsNameState { + pointsName: string; + loading: boolean; + fetchPointsName: (signal?: AbortSignal) => Promise; + setPointsName: (name: string) => void; +} + +// Matches the backend default when the setting has never been configured. +const DEFAULT_POINTS_NAME = "Points"; + +export const usePointsNameStore = create((set) => ({ + pointsName: DEFAULT_POINTS_NAME, + loading: false, + fetchPointsName: async (signal) => { + set({ loading: true }); + const res = await getRequest( + "/points-name", + "points name", + signal, + ); + if (signal?.aborted) return; + if (res.status === 200 && res.data) { + set({ pointsName: res.data.name, loading: false }); + } else { + // A missing label shouldn't surface an error on every page that shows + // points — keep the default and move on. + set({ loading: false }); + } + }, + setPointsName: (name) => set({ pointsName: name }), +})); diff --git a/client/web/src/types.ts b/client/web/src/types.ts index ca18d186..55171edf 100644 --- a/client/web/src/types.ts +++ b/client/web/src/types.ts @@ -82,6 +82,8 @@ export interface Application { responses: Record; /** Embedded on GET /applications/me; absent on mutation responses. */ application_schema?: ApplicationSchemaField[]; + /** Total scan points; populated on read endpoints. */ + points?: number; resume_path: string | null; ai_percent: number | null; accept_votes: number; @@ -161,6 +163,7 @@ export interface ApplicationListItem { reviews_completed: number; ai_percent: number | null; has_resume: boolean; + points: number; } // Paginated response from admin applications endpoint diff --git a/cmd/api/api.go b/cmd/api/api.go index 055ddb3c..52a4d241 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -180,6 +180,7 @@ func (app *application) mount() http.Handler { r.Get("/schedule/date-range", app.getHackerScheduleDateRange) r.Get("/faq", app.getHackerFAQHandler) r.Get("/hacker-pack", app.getHackerPackHandler) + r.Get("/points-name", app.getPointsNameHandler) r.Delete("/users/me", app.deleteMyAccountHandler) r.Get("/wallet/apple-pass/status", app.getAppleWalletStatusHandler) r.Get("/wallet/apple-pass", app.getAppleWalletPassHandler) @@ -298,6 +299,7 @@ func (app *application) mount() http.Handler { r.Post("/hackathon-date-range", app.setHackathonDateRange) r.Get("/hacker-pack-url", app.getHackerPackURL) r.Post("/hacker-pack-url", app.setHackerPackURL) + r.Post("/points-name", app.setPointsName) r.Put("/scan-types", app.updateScanTypesHandler) r.Get("/meal-groups", app.getMealGroups) r.Put("/meal-groups", app.updateMealGroups) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index 85f4b925..f4428009 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -21,6 +21,20 @@ type UpdateApplicationPayload struct { type ApplicationWithSchema struct { *store.Application ApplicationSchema []store.ApplicationSchemaField `json:"application_schema"` + // Points is the user's total scan points; populated on read endpoints only. + Points int `json:"points"` +} + +// userPoints returns the user's total scan points. Points are cosmetic, so a +// lookup failure is logged and reported as 0 rather than failing the request. +func (app *application) userPoints(r *http.Request, userID string) int { + points, err := app.store.Scans.GetTotalPointsByUserID(r.Context(), userID) + if err != nil { + app.logger.Warnw("failed to fetch scan points", "user_id", userID, "error", err) + return 0 + } + + return points } // getOrCreateApplicationHandler returns or creates the user's hackathon application @@ -76,6 +90,7 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: app.userPoints(r, user.ID), } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -162,6 +177,7 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: app.userPoints(r, user.ID), } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -596,6 +612,7 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: app.userPoints(r, application.UserID), } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go index ac63c47b..e9315036 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "net/http" "strings" "testing" @@ -39,6 +40,7 @@ func TestGetOrCreateApplication(t *testing.T) { app := newTestApplication(t) mockApps := app.store.Application.(*store.MockApplicationStore) mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) t.Run("should return existing application", func(t *testing.T) { user := newTestUser() @@ -47,6 +49,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + mockScans.On("GetTotalPointsByUserID", user.ID).Return(15, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -55,8 +58,17 @@ func TestGetOrCreateApplication(t *testing.T) { rr := executeRequest(req, http.HandlerFunc(app.getOrCreateApplicationHandler)) checkResponseCode(t, http.StatusOK, rr.Code) + var envelope struct { + Data struct { + Points int `json:"points"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope)) + assert.Equal(t, 15, envelope.Data.Points) + mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) }) t.Run("should create draft when no application exists", func(t *testing.T) { @@ -66,6 +78,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(nil, store.ErrNotFound).Once() mockApps.On("Create", mock.AnythingOfType("*store.Application")).Return(nil).Once() mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + mockScans.On("GetTotalPointsByUserID", user.ID).Return(0, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -76,6 +89,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) }) t.Run("should handle race condition on create conflict", func(t *testing.T) { @@ -87,6 +101,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.On("Create", mock.AnythingOfType("*store.Application")).Return(store.ErrConflict).Once() mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + mockScans.On("GetTotalPointsByUserID", user.ID).Return(0, nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) @@ -97,6 +112,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) }) } @@ -116,6 +132,7 @@ func TestUpdateApplication(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(existing, nil).Once() mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() mockApps.On("Update", mock.AnythingOfType("*store.Application")).Return(nil).Once() + app.store.Scans.(*store.MockScansStore).On("GetTotalPointsByUserID", user.ID).Return(0, nil).Once() body := `{"responses": {"first_name": "Jane", "last_name": "Doe"}}` req, err := http.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) @@ -585,6 +602,86 @@ func TestListApplications(t *testing.T) { }) } +func TestGetApplication(t *testing.T) { + schema := []store.ApplicationSchemaField{{ID: "first_name", Type: "text", Label: "First Name"}} + + newRequest := func(t *testing.T, applicationID string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newAdminUser()) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("applicationID", applicationID) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + } + + t.Run("should return application with points", func(t *testing.T) { + app := newTestApplication(t) + mockApps := app.store.Application.(*store.MockApplicationStore) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + + existing := newCompleteApplication("user-1") + mockApps.On("GetByID", "app-1").Return(existing, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + mockScans.On("GetTotalPointsByUserID", "user-1").Return(42, nil).Once() + + rr := executeRequest(newRequest(t, "app-1"), http.HandlerFunc(app.getApplication)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var envelope struct { + Data struct { + Points int `json:"points"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope)) + assert.Equal(t, 42, envelope.Data.Points) + + mockApps.AssertExpectations(t) + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + }) + + // Points are cosmetic — a failed lookup must not fail the whole read. + t.Run("should still return 200 with 0 points when lookup fails", func(t *testing.T) { + app := newTestApplication(t) + mockApps := app.store.Application.(*store.MockApplicationStore) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + + existing := newCompleteApplication("user-1") + mockApps.On("GetByID", "app-1").Return(existing, nil).Once() + mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + mockScans.On("GetTotalPointsByUserID", "user-1"). + Return(0, errors.New("scans unavailable")).Once() + + rr := executeRequest(newRequest(t, "app-1"), http.HandlerFunc(app.getApplication)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var envelope struct { + Data struct { + Points int `json:"points"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope)) + assert.Equal(t, 0, envelope.Data.Points) + + mockScans.AssertExpectations(t) + }) + + t.Run("should return 404 when application not found", func(t *testing.T) { + app := newTestApplication(t) + mockApps := app.store.Application.(*store.MockApplicationStore) + + mockApps.On("GetByID", "nonexistent").Return(nil, store.ErrNotFound).Once() + + rr := executeRequest(newRequest(t, "nonexistent"), http.HandlerFunc(app.getApplication)) + checkResponseCode(t, http.StatusNotFound, rr.Code) + + mockApps.AssertExpectations(t) + }) +} + func TestSetApplicationStatus(t *testing.T) { app := newTestApplication(t) mockApps := app.store.Application.(*store.MockApplicationStore) diff --git a/cmd/api/resume.go b/cmd/api/resume.go index 2acddd12..f4a162e7 100644 --- a/cmd/api/resume.go +++ b/cmd/api/resume.go @@ -149,6 +149,7 @@ func (app *application) deleteResumeHandler(w http.ResponseWriter, r *http.Reque response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: app.userPoints(r, user.ID), } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { diff --git a/cmd/api/resume_test.go b/cmd/api/resume_test.go index 0b35a47a..ff30a862 100644 --- a/cmd/api/resume_test.go +++ b/cmd/api/resume_test.go @@ -144,6 +144,7 @@ func TestDeleteResume(t *testing.T) { assert.Nil(t, updated.ResumePath) }).Return(nil).Once() mockSettings.On("GetApplicationSchema").Return(schema, nil).Once() + app.store.Scans.(*store.MockScansStore).On("GetTotalPointsByUserID", user.ID).Return(0, nil).Once() req, err := http.NewRequest(http.MethodDelete, "/", nil) require.NoError(t, err) diff --git a/cmd/api/scans.go b/cmd/api/scans.go index 3aa0a1c6..671a3fb4 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -35,6 +35,8 @@ type UpdateScanTypesPayload struct { type CreateScanResponse struct { *store.Scan MealGroup *string `json:"meal_group,omitempty"` + // Balance is the user's remaining points; populated only for shop scans. + Balance *int `json:"balance,omitempty"` } // getScanTypesHandler returns all configured scan types @@ -64,7 +66,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // createScanHandler records a scan for a user // // @Summary Create a scan (Admin) -// @Description Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. +// @Description Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. Shop scans deduct the type's points from the user's balance and are repeatable. // @Tags admin/scans // @Accept json // @Produce json @@ -72,6 +74,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // @Success 201 {object} CreateScanResponse // @Failure 400 {object} object{error=string} // @Failure 401 {object} object{error=string} +// @Failure 402 {object} object{error=string} "Insufficient points for shop scan" // @Failure 403 {object} object{error=string} // @Failure 409 {object} object{error=string} // @Failure 500 {object} object{error=string} @@ -181,9 +184,31 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request UserID: req.UserID, ScanType: req.ScanType, ScannedBy: admin.ID, + Points: found.Points, } - if err := app.store.Scans.Create(r.Context(), scan); err != nil { + var balance *int + if found.Category == store.ScanCategoryShop { + // Shop scans spend points: negate the configured cost and let the + // store verify the balance atomically. + scan.Points = -found.Points + + newBalance, err := app.store.Scans.CreatePurchase(r.Context(), scan) + if err != nil { + if errors.Is(err, store.ErrInsufficientPoints) { + writeJSONError(w, http.StatusPaymentRequired, + fmt.Sprintf("insufficient points: balance is %d, %s costs %d", newBalance, found.DisplayName, found.Points)) + return + } + if errors.Is(err, store.ErrNotFound) { + app.notFoundResponse(w, r, errors.New("user not found")) + return + } + app.internalServerError(w, r, err) + return + } + balance = &newBalance + } else if err := app.store.Scans.Create(r.Context(), scan); err != nil { if errors.Is(err, store.ErrConflict) { app.conflictResponse(w, r, errors.New("user already scanned for: "+req.ScanType)) return @@ -209,6 +234,7 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request response := CreateScanResponse{ Scan: scan, MealGroup: mealGroup, + Balance: balance, } if err := app.jsonResponse(w, http.StatusCreated, response); err != nil { diff --git a/cmd/api/scans_test.go b/cmd/api/scans_test.go index 7e617b9a..1c49b91f 100644 --- a/cmd/api/scans_test.go +++ b/cmd/api/scans_test.go @@ -49,8 +49,8 @@ func TestGetScanTypes(t *testing.T) { func TestCreateScan(t *testing.T) { scanTypes := []store.ScanType{ - {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true}, - {Name: "lunch", DisplayName: "Lunch", Category: store.ScanCategoryMeal, IsActive: true}, + {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true, Points: 10}, + {Name: "lunch", DisplayName: "Lunch", Category: store.ScanCategoryMeal, IsActive: true, Points: 5}, {Name: "inactive_item", DisplayName: "Inactive", Category: store.ScanCategorySwag, IsActive: false}, } @@ -69,7 +69,9 @@ func TestCreateScan(t *testing.T) { mockApps.On("GetByUserID", "user-1").Return(hackerApp, nil).Once() mockApps.On("SetMealGroup", "app-1", mock.AnythingOfType("string")). Return(&groups[0], nil).Once() - mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + mockScans.On("Create", mock.MatchedBy(func(s *store.Scan) bool { + return s.Points == 10 + })).Return(nil).Once() body := `{"user_id":"user-1","scan_type":"check_in"}` req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) @@ -87,6 +89,43 @@ func TestCreateScan(t *testing.T) { require.NoError(t, err) assert.NotNil(t, resp.Data.MealGroup) assert.Contains(t, groups, *resp.Data.MealGroup) + assert.Equal(t, 10, resp.Data.Points) + + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + mockApps.AssertExpectations(t) + }) + + t.Run("item scan awards the scan type's configured points", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockApps := app.store.Application.(*store.MockApplicationStore) + + mealGroup := "A" + + mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(true, nil).Once() + mockScans.On("Create", mock.MatchedBy(func(s *store.Scan) bool { + return s.Points == 5 + })).Return(nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return(&mealGroup, nil).Once() + + body := `{"user_id":"user-1","scan_type":"lunch"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + var resp struct { + Data CreateScanResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, 5, resp.Data.Points) mockSettings.AssertExpectations(t) mockScans.AssertExpectations(t) @@ -286,6 +325,103 @@ func TestCreateScan(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr.Code) }) + shopScanTypes := []store.ScanType{ + {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true, Points: 10}, + {Name: "hoodie", DisplayName: "Hoodie", Category: store.ScanCategoryShop, IsActive: true, Points: 50}, + } + + t.Run("shop scan deducts points and returns balance", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockApps := app.store.Application.(*store.MockApplicationStore) + + mealGroup := "A" + + mockSettings.On("GetScanTypes").Return(shopScanTypes, nil).Once() + mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(true, nil).Once() + mockScans.On("CreatePurchase", mock.MatchedBy(func(s *store.Scan) bool { + return s.Points == -50 + })).Return(70, nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return(&mealGroup, nil).Once() + + body := `{"user_id":"user-1","scan_type":"hoodie"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + var resp struct { + Data CreateScanResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, -50, resp.Data.Points) + require.NotNil(t, resp.Data.Balance) + assert.Equal(t, 70, *resp.Data.Balance) + + // Shop scans go through CreatePurchase so the balance check is atomic. + mockScans.AssertNotCalled(t, "Create", mock.Anything) + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + mockApps.AssertExpectations(t) + }) + + t.Run("402 when insufficient points for shop scan", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + + mockSettings.On("GetScanTypes").Return(shopScanTypes, nil).Once() + mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(true, nil).Once() + mockScans.On("CreatePurchase", mock.AnythingOfType("*store.Scan")). + Return(30, store.ErrInsufficientPoints).Once() + + body := `{"user_id":"user-1","scan_type":"hoodie"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusPaymentRequired, rr.Code) + + var errBody struct { + Error string `json:"error"` + } + err = json.NewDecoder(rr.Body).Decode(&errBody) + require.NoError(t, err) + assert.Contains(t, errBody.Error, "insufficient points") + + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + }) + + t.Run("403 shop scan without check-in", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + + mockSettings.On("GetScanTypes").Return(shopScanTypes, nil).Once() + mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(false, nil).Once() + + body := `{"user_id":"user-1","scan_type":"hoodie"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusForbidden, rr.Code) + + mockScans.AssertNotCalled(t, "CreatePurchase", mock.Anything) + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + }) + walkInScanTypes := []store.ScanType{ {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true}, {Name: "walk_in", DisplayName: "Walk-In", Category: store.ScanCategoryWalkIn, IsActive: true}, @@ -619,6 +755,66 @@ func TestUpdateScanTypes(t *testing.T) { mockSettings.AssertExpectations(t) }) + t.Run("accepts scan types with points", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + types := []store.ScanType{ + {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true, Points: 10}, + {Name: "walk_in", DisplayName: "Walk-In", Category: store.ScanCategoryWalkIn, IsActive: true}, + } + + mockSettings.On("UpdateScanTypes", types).Return(nil).Once() + + body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true,"points":10},{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true}]}` + 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.updateScanTypesHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + mockSettings.AssertExpectations(t) + }) + + t.Run("accepts shop category", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + types := []store.ScanType{ + {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true}, + {Name: "walk_in", DisplayName: "Walk-In", Category: store.ScanCategoryWalkIn, IsActive: true}, + {Name: "hoodie", DisplayName: "Hoodie", Category: store.ScanCategoryShop, IsActive: true, Points: 50}, + } + + mockSettings.On("UpdateScanTypes", types).Return(nil).Once() + + body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true},{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true},{"name":"hoodie","display_name":"Hoodie","category":"shop","is_active":true,"points":50}]}` + 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.updateScanTypesHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + mockSettings.AssertExpectations(t) + }) + + t.Run("400 negative points", func(t *testing.T) { + app := newTestApplication(t) + + body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true,"points":-5},{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true}]}` + 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.updateScanTypesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + t.Run("400 duplicate names", func(t *testing.T) { app := newTestApplication(t) diff --git a/cmd/api/settings.go b/cmd/api/settings.go index c90e8f66..1ecc8ca6 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -232,6 +232,14 @@ type HackerPackURLResponse struct { URL string `json:"url"` } +type SetPointsNamePayload struct { + Name string `json:"name" validate:"required,min=1,max=30"` +} + +type PointsNameResponse struct { + Name string `json:"name"` +} + // setReviewAssignmentToggle updates the review assignment enabled setting // // @Summary Set review assignment enabled state for a user (Super Admin) @@ -658,6 +666,68 @@ func (app *application) getHackerPackHandler(w http.ResponseWriter, r *http.Requ } } +// setPointsName updates the points system display name +// +// @Summary Set points system name (Super Admin) +// @Description Updates the display name of the points system shown to hackers and admins +// @Tags superadmin/settings +// @Accept json +// @Produce json +// @Param name body SetPointsNamePayload true "Points system name" +// @Success 200 {object} PointsNameResponse +// @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/settings/points-name [post] +func (app *application) setPointsName(w http.ResponseWriter, r *http.Request) { + var req SetPointsNamePayload + if err := readJSON(w, r, &req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + // Trim before validating so a whitespace-only name still fails min=1. + req.Name = strings.TrimSpace(req.Name) + if err := Validate.Struct(req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := app.store.Settings.SetPointsName(r.Context(), req.Name); err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, PointsNameResponse(req)); err != nil { + app.internalServerError(w, r, err) + } +} + +// getPointsNameHandler returns the configured points system name for any authenticated user. +// +// @Summary Get points system name +// @Description Returns the configured display name of the points system +// @Tags hackers +// @Produce json +// @Success 200 {object} PointsNameResponse +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /points-name [get] +func (app *application) getPointsNameHandler(w http.ResponseWriter, r *http.Request) { + name, err := app.store.Settings.GetPointsName(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, PointsNameResponse{Name: name}); err != nil { + app.internalServerError(w, r, err) + } +} + type UpdateMealGroupsPayload struct { Groups []string `json:"groups" validate:"max=50,dive,required,min=1,max=50"` } diff --git a/cmd/api/settings_test.go b/cmd/api/settings_test.go index c24ef626..db12bd22 100644 --- a/cmd/api/settings_test.go +++ b/cmd/api/settings_test.go @@ -600,6 +600,80 @@ func TestSetHackerPackURL(t *testing.T) { }) } +func TestGetPointsNameHandler(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should return name for hacker", func(t *testing.T) { + mockSettings.On("GetPointsName").Return("Nuggets", nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newTestUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getPointsNameHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data PointsNameResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "Nuggets", body.Data.Name) + + mockSettings.AssertExpectations(t) + }) +} + +func TestSetPointsName(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should trim and set name", func(t *testing.T) { + mockSettings.On("SetPointsName", "Tavern Points").Return(nil).Once() + + body := `{"name":" Tavern Points "}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.setPointsName)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var respBody struct { + Data PointsNameResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&respBody) + require.NoError(t, err) + assert.Equal(t, "Tavern Points", respBody.Data.Name) + + mockSettings.AssertExpectations(t) + }) + + t.Run("should reject empty name", func(t *testing.T) { + body := `{"name":" "}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.setPointsName)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("should reject name over 30 characters", func(t *testing.T) { + body := `{"name":"` + strings.Repeat("a", 31) + `"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.setPointsName)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) +} + func TestGetMealGroups(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) diff --git a/cmd/migrate/migrations/000024_alter_scans_add_points.down.sql b/cmd/migrate/migrations/000024_alter_scans_add_points.down.sql new file mode 100644 index 00000000..80f887fb --- /dev/null +++ b/cmd/migrate/migrations/000024_alter_scans_add_points.down.sql @@ -0,0 +1 @@ +ALTER TABLE scans DROP COLUMN IF EXISTS points; diff --git a/cmd/migrate/migrations/000024_alter_scans_add_points.up.sql b/cmd/migrate/migrations/000024_alter_scans_add_points.up.sql new file mode 100644 index 00000000..2aebb995 --- /dev/null +++ b/cmd/migrate/migrations/000024_alter_scans_add_points.up.sql @@ -0,0 +1 @@ +ALTER TABLE scans ADD COLUMN points INT NOT NULL DEFAULT 0; diff --git a/cmd/migrate/migrations/000025_alter_scans_add_repeatable.down.sql b/cmd/migrate/migrations/000025_alter_scans_add_repeatable.down.sql new file mode 100644 index 00000000..c951b48c --- /dev/null +++ b/cmd/migrate/migrations/000025_alter_scans_add_repeatable.down.sql @@ -0,0 +1,14 @@ +DROP INDEX IF EXISTS idx_scans_user_id; +DROP INDEX IF EXISTS uq_scans_user_scan_type_once; + +-- Restoring the constraint fails if repeatable duplicate rows exist; remove +-- all but the earliest scan per (user_id, scan_type) first. +DELETE FROM scans s +USING scans keep +WHERE s.user_id = keep.user_id + AND s.scan_type = keep.scan_type + AND (s.created_at, s.id) > (keep.created_at, keep.id); + +ALTER TABLE scans ADD CONSTRAINT scans_user_id_scan_type_key UNIQUE (user_id, scan_type); + +ALTER TABLE scans DROP COLUMN IF EXISTS repeatable; diff --git a/cmd/migrate/migrations/000025_alter_scans_add_repeatable.up.sql b/cmd/migrate/migrations/000025_alter_scans_add_repeatable.up.sql new file mode 100644 index 00000000..efbe2512 --- /dev/null +++ b/cmd/migrate/migrations/000025_alter_scans_add_repeatable.up.sql @@ -0,0 +1,10 @@ +-- Shop purchases may repeat per user; all other scan types stay once-per-user. +ALTER TABLE scans ADD COLUMN repeatable BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE scans DROP CONSTRAINT IF EXISTS scans_user_id_scan_type_key; + +CREATE UNIQUE INDEX uq_scans_user_scan_type_once ON scans(user_id, scan_type) WHERE NOT repeatable; + +-- Replaces the per-user lookup role of the dropped unique constraint's index +-- (balance SUMs and check-in existence checks). +CREATE INDEX idx_scans_user_id ON scans(user_id); diff --git a/docs/docs.go b/docs/docs.go index 8781e93e..f1ed27cd 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1201,7 +1201,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first.", + "description": "Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. Shop scans deduct the type's points from the user's balance and are repeatable.", "consumes": [ "application/json" ], @@ -1252,6 +1252,17 @@ const docTemplate = `{ } } }, + "402": { + "description": "Insufficient points for shop scan", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, "403": { "description": "Forbidden", "schema": { @@ -3213,6 +3224,53 @@ const docTemplate = `{ } } }, + "/points-name": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns the configured display name of the points system", + "produces": [ + "application/json" + ], + "tags": [ + "hackers" + ], + "summary": "Get points system name", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.PointsNameResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/public/faq": { "get": { "description": "Returns all frequently asked questions, ordered by display order", @@ -5309,6 +5367,89 @@ const docTemplate = `{ } } }, + "/superadmin/settings/points-name": { + "post": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Updates the display name of the points system shown to hackers and admins", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/settings" + ], + "summary": "Set points system name (Super Admin)", + "parameters": [ + { + "description": "Points system name", + "name": "name", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.SetPointsNamePayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.PointsNameResponse" + } + }, + "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/review-assignment-toggle": { "put": { "security": [ @@ -6136,6 +6277,10 @@ const docTemplate = `{ "meal_group": { "type": "string" }, + "points": { + "description": "Points is the user's total scan points; populated on read endpoints only.", + "type": "integer" + }, "reject_votes": { "type": "integer" }, @@ -6219,6 +6364,10 @@ const docTemplate = `{ "main.CreateScanResponse": { "type": "object", "properties": { + "balance": { + "description": "Balance is the user's remaining points; populated only for shop scans.", + "type": "integer" + }, "created_at": { "type": "string" }, @@ -6228,6 +6377,9 @@ const docTemplate = `{ "meal_group": { "type": "string" }, + "points": { + "type": "integer" + }, "scan_type": { "type": "string" }, @@ -6444,6 +6596,14 @@ const docTemplate = `{ } } }, + "main.PointsNameResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "main.PromoteWalkInsPayload": { "type": "object", "required": [ @@ -6721,6 +6881,19 @@ const docTemplate = `{ } } }, + "main.SetPointsNamePayload": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 30, + "minLength": 1 + } + } + }, "main.SetReviewAssignmentTogglePayload": { "type": "object", "required": [ @@ -7130,6 +7303,9 @@ const docTemplate = `{ "phone": { "type": "string" }, + "points": { + "type": "integer" + }, "reject_votes": { "type": "integer" }, @@ -7431,6 +7607,9 @@ const docTemplate = `{ "id": { "type": "string" }, + "points": { + "type": "integer" + }, "scan_type": { "type": "string" }, @@ -7470,7 +7649,8 @@ const docTemplate = `{ "meal", "swag", "other", - "walk_in" + "walk_in", + "shop" ], "allOf": [ { @@ -7490,6 +7670,10 @@ const docTemplate = `{ "type": "string", "maxLength": 50, "minLength": 1 + }, + "points": { + "type": "integer", + "minimum": 0 } } }, @@ -7500,14 +7684,16 @@ const docTemplate = `{ "meal", "swag", "other", - "walk_in" + "walk_in", + "shop" ], "x-enum-varnames": [ "ScanCategoryCheckIn", "ScanCategoryMeal", "ScanCategorySwag", "ScanCategoryOther", - "ScanCategoryWalkIn" + "ScanCategoryWalkIn", + "ScanCategoryShop" ] }, "store.ScheduleItem": { diff --git a/internal/store/applications.go b/internal/store/applications.go index 90483d9f..ee60cb64 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -80,6 +80,7 @@ type ApplicationListItem struct { AIPercent *int `json:"ai_percent"` HasResume bool `json:"has_resume"` MealGroup *string `json:"meal_group"` + Points int `json:"points"` } // ApplicationListResult contains paginated results @@ -368,7 +369,8 @@ func (s *ApplicationsStore) List( NULLIF(a.responses->>'hackathons_attended', '')::smallint AS hackathons_attended, a.submitted_at, a.created_at, a.updated_at, a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent, - a.resume_path IS NOT NULL AS has_resume, a.meal_group + a.resume_path IS NOT NULL AS has_resume, a.meal_group, + (SELECT COALESCE(SUM(s.points), 0) FROM scans s WHERE s.user_id = a.user_id) AS points FROM applications a INNER JOIN users u ON a.user_id = u.id` @@ -463,7 +465,7 @@ func (s *ApplicationsStore) List( &item.HackathonsAttended, &item.SubmittedAt, &item.CreatedAt, &item.UpdatedAt, &item.AcceptVotes, &item.RejectVotes, &item.WaitlistVotes, &item.ReviewsAssigned, &item.ReviewsCompleted, &item.AIPercent, - &item.HasResume, &item.MealGroup, + &item.HasResume, &item.MealGroup, &item.Points, ); err != nil { return nil, err } diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 302bad3d..0f13cc2d 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -271,6 +271,16 @@ func (m *MockSettingsStore) SetHackerPackURL(ctx context.Context, url string) er return args.Error(0) } +func (m *MockSettingsStore) GetPointsName(ctx context.Context) (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + +func (m *MockSettingsStore) SetPointsName(ctx context.Context, name string) error { + args := m.Called(name) + return args.Error(0) +} + func (m *MockSettingsStore) GetScanTypes(ctx context.Context) ([]ScanType, error) { args := m.Called() if args.Get(0) == nil { @@ -404,6 +414,11 @@ func (m *MockScansStore) Create(ctx context.Context, scan *Scan) error { return args.Error(0) } +func (m *MockScansStore) CreatePurchase(ctx context.Context, scan *Scan) (int, error) { + args := m.Called(scan) + return args.Int(0), args.Error(1) +} + func (m *MockScansStore) GetByUserID(ctx context.Context, userID string) ([]Scan, error) { args := m.Called(userID) if args.Get(0) == nil { @@ -425,6 +440,11 @@ func (m *MockScansStore) HasCheckIn(ctx context.Context, userID string, checkInT return args.Bool(0), args.Error(1) } +func (m *MockScansStore) GetTotalPointsByUserID(ctx context.Context, userID string) (int, error) { + args := m.Called(userID) + return args.Int(0), args.Error(1) +} + func (m *MockScansStore) RebalanceStats(ctx context.Context) ([]ScanStat, error) { args := m.Called() if args.Get(0) == nil { diff --git a/internal/store/scans.go b/internal/store/scans.go index 479f46c2..92c1fea3 100644 --- a/internal/store/scans.go +++ b/internal/store/scans.go @@ -19,13 +19,15 @@ const ( ScanCategorySwag ScanTypeCategory = "swag" ScanCategoryOther ScanTypeCategory = "other" ScanCategoryWalkIn ScanTypeCategory = "walk_in" + ScanCategoryShop ScanTypeCategory = "shop" ) type ScanType struct { Name string `json:"name" validate:"required,min=1,max=50"` DisplayName string `json:"display_name" validate:"required,min=1,max=100"` - Category ScanTypeCategory `json:"category" validate:"required,oneof=check_in meal swag other walk_in"` + Category ScanTypeCategory `json:"category" validate:"required,oneof=check_in meal swag other walk_in shop"` IsActive bool `json:"is_active"` + Points int `json:"points" validate:"min=0"` } type Scan struct { @@ -33,6 +35,7 @@ type Scan struct { UserID string `json:"user_id"` ScanType string `json:"scan_type"` ScannedBy string `json:"scanned_by"` + Points int `json:"points"` ScannedAt time.Time `json:"scanned_at"` CreatedAt time.Time `json:"created_at"` } @@ -57,12 +60,12 @@ func (s *ScansStore) Create(ctx context.Context, scan *Scan) error { defer tx.Rollback() query := ` - INSERT INTO scans (user_id, scan_type, scanned_by) - VALUES ($1, $2, $3) + INSERT INTO scans (user_id, scan_type, scanned_by, points) + VALUES ($1, $2, $3, $4) RETURNING id, scanned_at, created_at ` - err = tx.QueryRowContext(ctx, query, scan.UserID, scan.ScanType, scan.ScannedBy). + err = tx.QueryRowContext(ctx, query, scan.UserID, scan.ScanType, scan.ScannedBy, scan.Points). Scan(&scan.ID, &scan.ScannedAt, &scan.CreatedAt) if err != nil { var pgErr *pgconn.PgError @@ -84,12 +87,69 @@ func (s *ScansStore) Create(ctx context.Context, scan *Scan) error { return tx.Commit() } +// CreatePurchase inserts a repeatable scan with negative points after verifying +// the user's balance covers the cost. Returns the resulting balance. A +// per-user advisory lock serializes concurrent purchases so two scans cannot +// both pass the balance check; concurrent awards only increase the balance so +// they cannot invalidate a passed check. +func (s *ScansStore) CreatePurchase(ctx context.Context, scan *Scan) (int, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, scan.UserID); err != nil { + return 0, err + } + + var balance int + err = tx.QueryRowContext(ctx, `SELECT COALESCE(SUM(points), 0) FROM scans WHERE user_id = $1`, scan.UserID). + Scan(&balance) + if err != nil { + return 0, err + } + + if balance+scan.Points < 0 { + return balance, ErrInsufficientPoints + } + + query := ` + INSERT INTO scans (user_id, scan_type, scanned_by, points, repeatable) + VALUES ($1, $2, $3, $4, TRUE) + RETURNING id, scanned_at, created_at + ` + + err = tx.QueryRowContext(ctx, query, scan.UserID, scan.ScanType, scan.ScannedBy, scan.Points). + Scan(&scan.ID, &scan.ScannedAt, &scan.CreatedAt) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return 0, ErrNotFound + } + return 0, err + } + + if err := incrementScanStat(ctx, tx, scan.ScanType); err != nil { + return 0, err + } + + if err := tx.Commit(); err != nil { + return 0, err + } + + return balance + scan.Points, nil +} + func (s *ScansStore) GetByUserID(ctx context.Context, userID string) ([]Scan, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() query := ` - SELECT id, user_id, scan_type, scanned_by, scanned_at, created_at + SELECT id, user_id, scan_type, scanned_by, points, scanned_at, created_at FROM scans WHERE user_id = $1 ORDER BY scanned_at DESC @@ -104,7 +164,7 @@ func (s *ScansStore) GetByUserID(ctx context.Context, userID string) ([]Scan, er var scans []Scan for rows.Next() { var scan Scan - if err := rows.Scan(&scan.ID, &scan.UserID, &scan.ScanType, &scan.ScannedBy, &scan.ScannedAt, &scan.CreatedAt); err != nil { + if err := rows.Scan(&scan.ID, &scan.UserID, &scan.ScanType, &scan.ScannedBy, &scan.Points, &scan.ScannedAt, &scan.CreatedAt); err != nil { return nil, err } scans = append(scans, scan) @@ -173,6 +233,21 @@ func (s *ScansStore) HasCheckIn(ctx context.Context, userID string, checkInTypes return exists, nil } +// GetTotalPointsByUserID returns the sum of points across all of a user's scans. +func (s *ScansStore) GetTotalPointsByUserID(ctx context.Context, userID string) (int, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := `SELECT COALESCE(SUM(points), 0) FROM scans WHERE user_id = $1` + + var total int + if err := s.db.QueryRowContext(ctx, query, userID).Scan(&total); err != nil { + return 0, err + } + + return total, nil +} + // RebalanceStats recomputes the scan_stats counter cache from the authoritative // scans table and returns the recomputed stats (sorted by scan_type, matching // GetStats). The settings row is locked FOR UPDATE to serialize against diff --git a/internal/store/settings.go b/internal/store/settings.go index 9312b217..2545ffb7 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -24,6 +24,7 @@ const SettingsKeyHackathonDateRange = "hackathon_date_range" const SettingsKeyMealGroups = "meal_groups" const SettingsKeyApplicationsEnabled = "applications_enabled" const SettingsKeyHackerPackURL = "hacker_pack_url" +const SettingsKeyPointsName = "points_name" type HackathonDateRange struct { StartDate *string `json:"start_date"` @@ -562,6 +563,55 @@ func (s *SettingsStore) SetHackerPackURL(ctx context.Context, url string) error return err } +// GetPointsName returns the configured display name of the points system. +// Defaults to "Points" if the row does not exist (not configured). +func (s *SettingsStore) GetPointsName(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT value + FROM settings + WHERE key = $1 + ` + + var value []byte + err := s.db.QueryRowContext(ctx, query, SettingsKeyPointsName).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "Points", nil + } + return "", err + } + + var name string + if err := json.Unmarshal(value, &name); err != nil { + return "", err + } + + return name, nil +} + +// SetPointsName updates the display name of the points system. +func (s *SettingsStore) SetPointsName(ctx context.Context, name string) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + jsonValue, err := json.Marshal(name) + if err != nil { + return err + } + + query := ` + INSERT INTO settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + + _, err = s.db.ExecContext(ctx, query, SettingsKeyPointsName, string(jsonValue)) + return err +} + // GetMealGroups returns the configured list of meal group names (e.g., ["A", "B", "C", "D"]) func (s *SettingsStore) GetMealGroups(ctx context.Context) ([]string, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) diff --git a/internal/store/storage.go b/internal/store/storage.go index 7e4c6cd4..7907d5b8 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -10,9 +10,10 @@ import ( ) var ( - ErrNotFound = errors.New("resource not found") - ErrConflict = errors.New("resource already exists") - QueryTimeoutDuration = time.Second * 5 + ErrNotFound = errors.New("resource not found") + ErrConflict = errors.New("resource already exists") + ErrInsufficientPoints = errors.New("insufficient points") + QueryTimeoutDuration = time.Second * 5 ) type Storage struct { @@ -56,6 +57,8 @@ type Storage struct { SetHackathonDateRange(ctx context.Context, dateRange HackathonDateRange) error GetHackerPackURL(ctx context.Context) (string, error) SetHackerPackURL(ctx context.Context, url string) error + GetPointsName(ctx context.Context) (string, error) + SetPointsName(ctx context.Context, name string) error GetScanTypes(ctx context.Context) ([]ScanType, error) UpdateScanTypes(ctx context.Context, scanTypes []ScanType) error GetScanStats(ctx context.Context) (map[string]int, error) @@ -74,9 +77,11 @@ type Storage struct { } Scans interface { Create(ctx context.Context, scan *Scan) error + CreatePurchase(ctx context.Context, scan *Scan) (int, error) GetByUserID(ctx context.Context, userID string) ([]Scan, error) GetStats(ctx context.Context) ([]ScanStat, error) HasCheckIn(ctx context.Context, userID string, checkInTypes []string) (bool, error) + GetTotalPointsByUserID(ctx context.Context, userID string) (int, error) RebalanceStats(ctx context.Context) ([]ScanStat, error) } ApplicationReviews interface {