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..4a6eaca5 100644 --- a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx +++ b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx @@ -76,6 +76,9 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ {application.status} + + {application.points ?? 0} pts + ) : 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..69d602df 100644 --- a/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx +++ b/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx @@ -51,12 +51,13 @@ export const ApplicationsTable = memo(function ApplicationsTable({ Created Updated AI Percent + Points {applications.length === 0 ? ( - + No applications found @@ -107,6 +108,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..ff47f191 100644 --- a/client/web/src/pages/admin/scans/ScansPage.tsx +++ b/client/web/src/pages/admin/scans/ScansPage.tsx @@ -20,6 +20,7 @@ export default function ScansPage() { rebalancing, fetchTypes, fetchStats, + fetchPointsName, saveScanTypes, rebalanceStats, setActiveScanType, @@ -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/api.ts b/client/web/src/pages/admin/scans/api.ts index b8fd7a8f..e9f5ccd3 100644 --- a/client/web/src/pages/admin/scans/api.ts +++ b/client/web/src/pages/admin/scans/api.ts @@ -2,6 +2,7 @@ import { getRequest, postRequest, putRequest } from "@/shared/lib/api"; import type { ApiResponse } from "@/types"; import type { + PointsNameResponse, Scan, ScanStatsResponse, ScanType, @@ -61,3 +62,19 @@ export async function saveScanTypes( "scan types", ); } + +export async function fetchPointsName( + signal?: AbortSignal, +): Promise> { + return getRequest("/points-name", "points name", signal); +} + +export async function savePointsName( + name: string, +): Promise> { + return postRequest( + "/superadmin/settings/points-name", + { name }, + "points name", + ); +} diff --git a/client/web/src/pages/admin/scans/components/PointsNameCard.tsx b/client/web/src/pages/admin/scans/components/PointsNameCard.tsx new file mode 100644 index 00000000..9616712e --- /dev/null +++ b/client/web/src/pages/admin/scans/components/PointsNameCard.tsx @@ -0,0 +1,71 @@ +import { Coins, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +import { useScansStore } from "../store"; + +interface PointsNameCardProps { + isSuperAdmin: boolean; +} + +export function PointsNameCard({ isSuperAdmin }: PointsNameCardProps) { + const { pointsName, savingPointsName, savePointsName } = useScansStore(); + const [draft, setDraft] = useState(pointsName); + + useEffect(() => { + setDraft(pointsName); + }, [pointsName]); + + if (!isSuperAdmin) { + return null; + } + + const dirty = draft.trim() !== pointsName; + + const handleSave = async () => { + const trimmed = draft.trim(); + if (!trimmed) { + toast.error("Points system name must not be empty"); + return; + } + if (trimmed.length > 30) { + toast.error("Points system name must be at most 30 characters"); + return; + } + await savePointsName(trimmed); + }; + + return ( +
+ + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && dirty) handleSave(); + }} + placeholder="Points system name" + className="h-8 w-44 text-sm font-light" + maxLength={30} + /> + {dirty && ( + + )} +
+ ); +} 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..b7ebccbb 100644 --- a/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx +++ b/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx @@ -72,6 +72,7 @@ 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); @@ -90,6 +91,7 @@ export function ScanTypesTable({ setEditingIndex(index); if (scanTypes[index]) { setEditDisplayName(scanTypes[index].display_name); + setEditPoints(String(scanTypes[index].points ?? 0)); } }; @@ -97,6 +99,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 +109,7 @@ export function ScanTypesTable({ ...pendingNew, display_name: trimmed, name: toSnakeCase(trimmed), + points, }; const updated = [...scanTypes, newType]; @@ -124,11 +129,11 @@ export function ScanTypesTable({ if (!current) return; // No change — skip save - if (trimmed === current.display_name) return; + if (trimmed === current.display_name && points === current.points) return; const updated = scanTypes.map((st, i) => i === editingIndex - ? { ...st, display_name: trimmed, name: toSnakeCase(trimmed) } + ? { ...st, display_name: trimmed, name: toSnakeCase(trimmed), points } : st, ); @@ -139,7 +144,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 +209,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 +309,7 @@ export function ScanTypesTable({ Action Name Category + Points Scans {isSuperAdmin && Active} @@ -375,6 +390,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 +499,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..782041ef 100644 --- a/client/web/src/pages/admin/scans/components/ScannerDialog.tsx +++ b/client/web/src/pages/admin/scans/components/ScannerDialog.tsx @@ -19,6 +19,7 @@ export function ScannerDialog() { activeScanType, lastScanResult, scanning, + pointsName, setActiveScanType, performScan, clearLastResult, @@ -103,6 +104,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/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..7d247019 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,8 @@ 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.Get("/points-name", app.getPointsName) + 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..120d4c34 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -21,6 +21,8 @@ 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"` } // getOrCreateApplicationHandler returns or creates the user's hackathon application @@ -73,9 +75,16 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * return } + points, err := app.store.Scans.GetTotalPointsByUserID(r.Context(), user.ID) + if err != nil { + app.internalServerError(w, r, err) + return + } + response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: points, } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -159,9 +168,16 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. return } + points, err := app.store.Scans.GetTotalPointsByUserID(r.Context(), user.ID) + if err != nil { + app.internalServerError(w, r, err) + return + } + response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: points, } if err := app.jsonResponse(w, http.StatusOK, response); err != nil { @@ -593,9 +609,16 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { return } + points, err := app.store.Scans.GetTotalPointsByUserID(r.Context(), application.UserID) + if err != nil { + app.internalServerError(w, r, err) + return + } + response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: points, } 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..efd7184a 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -39,6 +39,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 +48,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 +57,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 +77,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 +88,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 +100,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 +111,7 @@ func TestGetOrCreateApplication(t *testing.T) { mockApps.AssertExpectations(t) mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) }) } @@ -116,6 +131,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)) diff --git a/cmd/api/resume.go b/cmd/api/resume.go index 2acddd12..0d1fd9b5 100644 --- a/cmd/api/resume.go +++ b/cmd/api/resume.go @@ -146,9 +146,16 @@ func (app *application) deleteResumeHandler(w http.ResponseWriter, r *http.Reque return } + points, err := app.store.Scans.GetTotalPointsByUserID(r.Context(), user.ID) + if err != nil { + app.internalServerError(w, r, err) + return + } + response := ApplicationWithSchema{ Application: application, ApplicationSchema: schema, + Points: points, } 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..145ceb7c 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,101 @@ 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) + + 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 +753,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..a69429ae 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"` +} + +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,95 @@ func (app *application) getHackerPackHandler(w http.ResponseWriter, r *http.Requ } } +// getPointsName returns the configured points system display name +// +// @Summary Get points system name (Super Admin) +// @Description Returns the configured display name of the points system +// @Tags superadmin/settings +// @Produce json +// @Success 200 {object} PointsNameResponse +// @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 [get] +func (app *application) getPointsName(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) + } +} + +// 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 + } + + name := strings.TrimSpace(req.Name) + if name == "" { + app.badRequestResponse(w, r, errors.New("name must not be empty")) + return + } + if len(name) > 30 { + app.badRequestResponse(w, r, errors.New("name must be at most 30 characters")) + return + } + + if err := app.store.Settings.SetPointsName(r.Context(), name); 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) + } +} + +// 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..9d0d345e 100644 --- a/cmd/api/settings_test.go +++ b/cmd/api/settings_test.go @@ -600,6 +600,105 @@ func TestSetHackerPackURL(t *testing.T) { }) } +func TestGetPointsName(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should return configured name", func(t *testing.T) { + mockSettings.On("GetPointsName").Return("Tavern Points", nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getPointsName)) + 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, "Tavern Points", body.Data.Name) + + mockSettings.AssertExpectations(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..805c621a --- /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 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..0ff8dbf4 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,145 @@ const docTemplate = `{ } } }, + "/superadmin/settings/points-name": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns the configured display name of the points system", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/settings" + ], + "summary": "Get points system name (Super Admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.PointsNameResponse" + } + }, + "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" + } + } + } + } + } + }, + "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 +6333,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 +6420,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 +6433,9 @@ const docTemplate = `{ "meal_group": { "type": "string" }, + "points": { + "type": "integer" + }, "scan_type": { "type": "string" }, @@ -6444,6 +6652,14 @@ const docTemplate = `{ } } }, + "main.PointsNameResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "main.PromoteWalkInsPayload": { "type": "object", "required": [ @@ -6721,6 +6937,14 @@ const docTemplate = `{ } } }, + "main.SetPointsNamePayload": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "main.SetReviewAssignmentTogglePayload": { "type": "object", "required": [ @@ -7130,6 +7354,9 @@ const docTemplate = `{ "phone": { "type": "string" }, + "points": { + "type": "integer" + }, "reject_votes": { "type": "integer" }, @@ -7431,6 +7658,9 @@ const docTemplate = `{ "id": { "type": "string" }, + "points": { + "type": "integer" + }, "scan_type": { "type": "string" }, @@ -7470,7 +7700,8 @@ const docTemplate = `{ "meal", "swag", "other", - "walk_in" + "walk_in", + "shop" ], "allOf": [ { @@ -7490,6 +7721,10 @@ const docTemplate = `{ "type": "string", "maxLength": 50, "minLength": 1 + }, + "points": { + "type": "integer", + "minimum": 0 } } }, @@ -7500,14 +7735,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 {