diff --git a/.claude/skills/ci-pipeline/SKILL.md b/.claude/skills/ci-pipeline/SKILL.md index d153cc464..20fd80aa2 100644 --- a/.claude/skills/ci-pipeline/SKILL.md +++ b/.claude/skills/ci-pipeline/SKILL.md @@ -49,14 +49,14 @@ fails, the check is red and the PR can't merge cleanly. ### `backend-audit` job (Go) -Go version **1.24.x**. Steps, in order — each is a gate: +Go version **1.27.x**. Steps, in order — each is a gate: 1. **Check gofmt** — `gofmt -l .`; fails if any file is unformatted. Fix with `gofmt -w .`. 2. **Verify Dependencies** — `go mod verify`. 3. **Build** — `go build -v ./...`. 4. **go vet** — `go vet ./...`. -5. **staticcheck** — installs `honnef.co/go/tools/cmd/staticcheck@v0.6.1`, then +5. **staticcheck** — installs `honnef.co/go/tools/cmd/staticcheck@v0.8.1`, then `staticcheck ./...`. 6. **Tests** — `go test -race ./...` (race detector on). @@ -134,7 +134,7 @@ Multi-stage, producing a tiny `scratch` image: 1. **Stage `frontend`** (`node:22-alpine`): `npm ci` then `npm run build` in `client/portal`. Takes a build arg `VITE_GOOGLE_AUTH_ENABLED` (default `true`). -2. **Stage `builder`** (`golang:1.24`): `go mod download`, then a static build +2. **Stage `builder`** (`golang:1.27`): `go mod download`, then a static build `CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app/api ./cmd/api`. 3. **Stage final** (`scratch`): copies CA certs, the `api` binary, and the built frontend into `./static`. `EXPOSE 8080`, `CMD ["./api"]`. diff --git a/.env.example b/.env.example index 82fa37d3a..7492e52ff 100644 --- a/.env.example +++ b/.env.example @@ -146,6 +146,12 @@ VAPID_PRIVATE_KEY= # Contact address for push services, used if a provider needs to reach you. VAPID_SUBJECT=noreply@example.com +# Comma-separated push-service hosts (exact or any subdomain) that browser +# subscription endpoints must point at. The server POSTs to these URLs, so +# anything else is rejected. Leave empty for the built-in list covering +# Chrome/Edge (FCM), Firefox, Safari, Windows (WNS) and Samsung Internet. +PUSH_ENDPOINT_ALLOWED_HOSTS= + # ── Rate limiting ──────────────────────────────────────────────────────────── @@ -160,6 +166,20 @@ RATELIMITER_REQUESTS_COUNT=20 # at the venue typically shares one IP. RATELIMITER_IP_REQUESTS_COUNT=200 +# How the per-IP limiter learns the client address. Forwarded headers are +# never trusted by default because any client can send them. +# +# CLIENT_IP_HEADER: a single-IP header your edge proxy OVERWRITES on every +# request (Cloudflare: CF-Connecting-IP, nginx realip: X-Real-IP). Leave +# empty if no such proxy exists. +# CLIENT_IP_TRUSTED_PROXIES: used only when CLIENT_IP_HEADER is empty; the +# number of reverse proxies between the internet and this server; the +# X-Forwarded-For entry that many hops from the right is the client (one load +# balancer: 1). Verify with a request from a known IP. 0 means the TCP peer +# address is used as-is. +CLIENT_IP_HEADER=CF-Connecting-IP +CLIENT_IP_TRUSTED_PROXIES=0 + # ── Apple Wallet passes (optional) ─────────────────────────────────────────── diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml index 16411b186..951d14865 100644 --- a/.github/workflows/audit.yaml +++ b/.github/workflows/audit.yaml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.24.x" + go-version: "1.27.x" - name: Check gofmt run: | @@ -42,7 +42,7 @@ jobs: run: go vet ./... - name: Install staticcheck - run: go install honnef.co/go/tools/cmd/staticcheck@v0.6.1 + run: go install honnef.co/go/tools/cmd/staticcheck@v0.8.1 - name: Run staticcheck run: staticcheck ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2344180..5d25d0454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [0.14.0](https://github.com/hackutd/harp/compare/v0.13.0...v0.14.0) (2026-09-08) + + +### Features + +* public endpoint for tracks ([#159](https://github.com/hackutd/harp/issues/159)) ([4228aef](https://github.com/hackutd/harp/commit/4228aefeb92f130c42b114c5612e28c18c548c65)) + + +### Bug Fixes + +* (sa): form unwanted scroll ([e216a57](https://github.com/hackutd/harp/commit/e216a57ee85175925565ed87c17960b1d5ee4519)) +* a more balanced review process & confirmation dialogs ([#156](https://github.com/hackutd/harp/issues/156)) ([831806d](https://github.com/hackutd/harp/commit/831806d40248085e2401573020ce16ed5f197d20)) +* error messaging on applications ([#157](https://github.com/hackutd/harp/issues/157)) ([72533ac](https://github.com/hackutd/harp/commit/72533acfa7073884e53c9dfbaf3271e37abff1b9)) +* **notifications:** restrict push endpoints to known push services and bound dispatcher requests ([#153](https://github.com/hackutd/harp/issues/153)) ([9ca9dff](https://github.com/hackutd/harp/commit/9ca9dffe513669c2d355689a613faed4c95e7c94)) +* **ratelimiter:** atomic fixed-window counting and explicit client-IP trust ([#155](https://github.com/hackutd/harp/issues/155)) ([dd215e6](https://github.com/hackutd/harp/commit/dd215e69f45b9a34eddddf81b086d12a8b3145ad)) +* required conditional check against application form ([95030e0](https://github.com/hackutd/harp/commit/95030e07918e629bce96e23d8ab823f91001ed55)) + +## [0.13.0](https://github.com/hackutd/harp/compare/v0.12.0...v0.13.0) (2026-09-02) + + +### Features + +* "Add to Home Screen" wording + "Get Notified" push dialog ([#142](https://github.com/hackutd/harp/issues/142)) ([db20740](https://github.com/hackutd/harp/commit/db20740136e4917f4252c849e1422f098939d6f9)) +* auto-open install walkthrough on mobile browsers instead of toast ([#144](https://github.com/hackutd/harp/issues/144)) ([74598c5](https://github.com/hackutd/harp/commit/74598c58fe3ce771adaa667fe4c47b17f8ee3937)) +* hide hacker information from admins ([#140](https://github.com/hackutd/harp/issues/140)) ([f9ec8d0](https://github.com/hackutd/harp/commit/f9ec8d09e6533372ab8a95ca1f3013047c50d513)) +* install walkthrough slideshow + push notification dialog ([#138](https://github.com/hackutd/harp/issues/138)) ([67c84cc](https://github.com/hackutd/harp/commit/67c84cc7754d25f3cbfcfd44d186e1e11bb24eae)) +* many frontend improvements & delete user ([#151](https://github.com/hackutd/harp/issues/151)) ([7bf35b0](https://github.com/hackutd/harp/commit/7bf35b0078d6f7930efdbcb31b5a7dd252e664b9)) +* performance optimizations ([#149](https://github.com/hackutd/harp/issues/149)) ([ae11579](https://github.com/hackutd/harp/commit/ae115796032c1f1df0a55b8261ce12c4791a07b2)) +* rsvp and travel ([#145](https://github.com/hackutd/harp/issues/145)) ([49ba0e8](https://github.com/hackutd/harp/commit/49ba0e892e90e61ae4dffd9134e18e71ec52dc19)) +* superadmin-configurable Hacker Links shown as cards on hacker home ([#143](https://github.com/hackutd/harp/issues/143)) ([f3d6a1c](https://github.com/hackutd/harp/commit/f3d6a1c7cfd60f37cbb82efa97ed0c75fda94640)) +* surface hacker meal group in portal UI ([#139](https://github.com/hackutd/harp/issues/139)) ([4a20ee8](https://github.com/hackutd/harp/commit/4a20ee82d79960ee24f0f46f7ef0e2dd11252c60)) + + +### Bug Fixes + +* key rate limiter by session user with per-IP fallback ([#152](https://github.com/hackutd/harp/issues/152)) ([cca2761](https://github.com/hackutd/harp/commit/cca2761f4e9f855ffbe9c60c2d0ae038da4378f0)) + ## [0.12.0](https://github.com/hackutd/harp/compare/v0.11.0...v0.12.0) (2026-08-27) diff --git a/Dockerfile b/Dockerfile index 337dd5f32..af1c7fea6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV VITE_GOOGLE_AUTH_ENABLED=$VITE_GOOGLE_AUTH_ENABLED RUN npm run build # Stage 2: Build backend -FROM golang:1.24.13 AS builder +FROM golang:1.27.1 AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/Taskfile.yml b/Taskfile.yml index ae7054df6..72555d6f7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -32,6 +32,11 @@ tasks: cmds: - go run cmd/migrate/seed/main.go + reset-schema: + desc: Restore form schemas to the shipped defaults (task reset-schema -- -all -dry-run) + cmds: + - go run ./cmd/resetschema {{.CLI_ARGS}} + gen-docs: desc: Generate Swagger docs cmds: diff --git a/claude.md b/claude.md index fd2e67edc..8419039f5 100644 --- a/claude.md +++ b/claude.md @@ -55,8 +55,8 @@ Note: `air` runs `task gen-docs` as a pre-command on every rebuild, so `swag` CL - **Entry point:** `cmd/api/main.go` — loads config, `cmd/api/api.go` — Chi router setup in `mount()` - **Database:** PostgreSQL 16.3, raw SQL (no ORM), repository pattern in `internal/store/` - **Auth:** SuperTokens (Passwordless magic link + Google OAuth), initialized in `internal/auth/` -- **Middleware chain:** RequestID → RealIP → Logger → Recoverer → CORS → SuperTokens → RateLimiter (`/v1` only) → AuthRequired → RequireRole -- **Rate limiting:** keyed by SuperTokens user ID when the request carries a verified session (`RATELIMITER_REQUESTS_COUNT`), falling back to client IP otherwise (`RATELIMITER_IP_REQUESTS_COUNT`, larger because a whole venue shares one NAT). Static assets and `/auth/*` are never limited. +- **Middleware chain:** RequestID → ClientIP → Logger → Recoverer → CORS → SuperTokens → RateLimiter (`/v1` only) → AuthRequired → RequireRole +- **Rate limiting:** keyed by SuperTokens user ID when the request carries a verified session (`RATELIMITER_REQUESTS_COUNT`), falling back to client IP otherwise (`RATELIMITER_IP_REQUESTS_COUNT`, larger because a whole venue shares one NAT). The client IP comes from `CLIENT_IP_HEADER` (default `CF-Connecting-IP`) or `CLIENT_IP_TRUSTED_PROXIES` hops into `X-Forwarded-For`; other forwarded headers are ignored. Static assets and `/auth/*` are never limited. - **Roles (hierarchical):** `hacker` (1) < `admin` (2) < `super_admin` (3) - **JSON envelope:** Success: `{"data": ...}`, Error: `{"error": "..."}` - **Pagination:** Cursor-based with base64-encoded JSON cursors diff --git a/client/portal/package-lock.json b/client/portal/package-lock.json index 9fb01b1c9..259716ec6 100644 --- a/client/portal/package-lock.json +++ b/client/portal/package-lock.json @@ -70,6 +70,7 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "esbuild": "0.27.3", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-boundaries": "^6.0.2", diff --git a/client/portal/package.json b/client/portal/package.json index 14a666cc9..40c6ff64a 100644 --- a/client/portal/package.json +++ b/client/portal/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "format": "prettier --write \"{src,branding}/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"{src,branding}/**/*.{ts,tsx,css,json}\"", - "preview": "vite preview" + "preview": "vite preview", + "test:reviews": "node --test scripts/review-regressions.test.mjs" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -74,6 +75,7 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "esbuild": "0.27.3", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-boundaries": "^6.0.2", diff --git a/client/portal/scripts/review-regressions.test.mjs b/client/portal/scripts/review-regressions.test.mjs new file mode 100644 index 000000000..cdb2ea7c5 --- /dev/null +++ b/client/portal/scripts/review-regressions.test.mjs @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { beforeEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const project = fileURLToPath(new URL("../", import.meta.url)); +const require = createRequire(new URL("../package.json", import.meta.url)); +const bundle = await build({ + absWorkingDir: project, + stdin: { + contents: ` + export { useAdminGradingStore as grading } from './src/pages/admin/reviews/grading/store.ts'; + export { useReviewsStore as list } from './src/pages/admin/reviews/store.ts'; + export { useReviewApplicationsStore as applications } from './src/pages/superadmin/reviews/store.ts'; + `, + resolveDir: project, + loader: "ts", + }, + platform: "node", + format: "cjs", + bundle: true, + write: false, + plugins: [ + { + name: "silent-toasts", + setup(b) { + b.onResolve({ filter: /^sonner$/ }, () => ({ + path: "toast", + namespace: "test", + })); + b.onLoad({ filter: /.*/, namespace: "test" }, () => ({ + contents: + "export const toast = { success(){}, warning(){}, error(){} };", + })); + }, + }, + ], +}); +const module = { exports: {} }; +new Function("module", "exports", "require", bundle.outputFiles[0].text)( + module, + module.exports, + require, +); +const { grading, list, applications } = module.exports; +const reviews = [1, 2, 3].map((i) => ({ + id: `r${i}`, + application_id: `a${i}`, + vote: null, + travel_status: "not_requested", +})); +const pendingPath = "/v1/admin/reviews/pending"; +const completedPath = "/v1/admin/reviews/completed"; +const appPath = "/v1/admin/applications"; +let handlers; +let requests; +let failVote; + +function ok(data) { + return Response.json({ data }); +} +function failed() { + return Response.json({ error: "Database unavailable" }, { status: 500 }); +} +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +function appList(ids = ["a1"]) { + return { + applications: ids.map((id) => ({ id })), + next_cursor: null, + prev_cursor: null, + has_more: false, + }; +} + +beforeEach(() => { + grading.getState().reset(); + list.getState().setTab("assigned"); + applications.getState().resetPagination(); + handlers = new Map(); + requests = []; + failVote = false; + // Exercise the real API wrappers as well as the actual bundled stores. + globalThis.fetch = async (url, options) => { + const path = new URL(url, "http://test.local").pathname; + requests.push({ url, ...options }); + if (handlers.has(path)) return handlers.get(path)(url, options); + if (path === pendingPath) return ok({ reviews }); + if (path === completedPath) return ok({ reviews: [] }); + if (path === appPath) return ok(appList()); + if (path === `${appPath}/stats`) return ok({ submitted: 1 }); + if (path.endsWith("/notes")) return ok({ notes: [] }); + if (path.startsWith(`${appPath}/`)) + return ok({ id: path.split("/").at(-1), ai_percent: null }); + if (path.startsWith("/v1/admin/reviews/") && options.method === "PUT") { + return failVote ? failed() : ok({}); + } + throw new Error(`Unexpected request: ${options.method} ${url}`); + }; +}); + +for (const tab of ["assigned", "completed"]) { + test(`${tab} queue distinguishes failure from empty and supports retry`, async () => { + list.getState().setTab(tab); + const path = tab === "assigned" ? pendingPath : completedPath; + handlers.set(path, failed); + await list.getState().fetchReviews(); + assert.equal(list.getState().error, "Database unavailable"); + assert.equal(list.getState().loading, false); + handlers.set(path, () => ok({ reviews })); + await list.getState().fetchReviews(); + assert.equal(list.getState().error, null); + assert.equal(list.getState().reviews.length, 3); + handlers.set(path, () => ok({ reviews: [] })); + await list.getState().fetchReviews(); + assert.equal(list.getState().error, null); + assert.deepEqual(list.getState().reviews, []); + }); +} + +test("network failures remain visible in both review queues", async () => { + handlers.set(pendingPath, () => { + throw new Error("Network offline"); + }); + await list.getState().fetchReviews(); + await grading.getState().fetchReviews(); + for (const store of [list, grading]) { + assert.equal(store.getState().error, "Network offline"); + assert.equal(store.getState().loading, false); + } +}); + +test("grading retry selects the requested review and loads its detail and notes", async () => { + handlers.set(pendingPath, failed); + await grading.getState().fetchReviews("r2"); + assert.equal(grading.getState().error, "Database unavailable"); + handlers.delete(pendingPath); + await grading.getState().fetchReviews("r2"); + assert.equal(grading.getState().error, null); + assert.equal(grading.getState().currentIndex, 1); + assert.equal(grading.getState().detail.id, "a2"); + assert.equal(grading.getState().notesLoading, false); + assert.ok(requests.some((r) => r.url === `${appPath}/a2/notes`)); + await grading.getState().fetchReviews("removed-review"); + assert.equal(grading.getState().currentIndex, 0); + assert.equal(grading.getState().detail.id, "a1"); +}); + +test("a successful empty grading queue has no error or stale detail", async () => { + await grading.getState().fetchReviews(); + handlers.set(pendingPath, () => ok({ reviews: [] })); + await grading.getState().fetchReviews(); + assert.equal(grading.getState().error, null); + assert.equal(grading.getState().detail, null); + assert.deepEqual(grading.getState().reviews, []); +}); + +test("grading advances through every assigned review and retains a failed vote", async () => { + await grading.getState().fetchReviews(); + failVote = true; + await grading.getState().submitVote("r1", "accept"); + assert.equal(grading.getState().reviews.length, 3); + assert.equal(grading.getState().submitting, false); + failVote = false; + for (const id of ["r1", "r2", "r3"]) { + const state = grading.getState(); + assert.equal(state.reviews[state.currentIndex].id, id); + await state.submitVote(id, "accept"); + } + assert.equal(grading.getState().reviews.length, 0); + assert.equal(grading.getState().detail, null); +}); + +for (const [name, store] of [ + ["list", list], + ["grading", grading], +]) { + test(`${name} ignores superseded fetch responses`, async () => { + const older = deferred(); + handlers.set(pendingPath, () => older.promise); + const first = store.getState().fetchReviews(); + handlers.set(pendingPath, () => ok({ reviews: [reviews[2]] })); + await store.getState().fetchReviews(); + older.resolve(failed()); + await first; + assert.equal(store.getState().error, null); + assert.equal(store.getState().reviews[0].id, "r3"); + }); + test(`${name} abort releases loading without presenting an error`, async () => { + const response = deferred(); + handlers.set(pendingPath, () => response.promise); + const controller = new AbortController(); + const request = + name === "list" + ? store.getState().fetchReviews(controller.signal) + : store.getState().fetchReviews(undefined, controller.signal); + controller.abort(); + response.resolve(failed()); + await request; + assert.equal(store.getState().error, null); + assert.equal(store.getState().loading, false); + assert.deepEqual(store.getState().reviews, []); + }); +} + +test("switching queue tabs invalidates the previous tab's request", async () => { + const older = deferred(); + handlers.set(pendingPath, () => older.promise); + const first = list.getState().fetchReviews(); + list.getState().setTab("completed"); + await list.getState().fetchReviews(); + older.resolve(ok({ reviews })); + await first; + assert.equal(list.getState().tab, "completed"); + assert.deepEqual(list.getState().reviews, []); +}); + +test("reset invalidates pending grading requests and detail requests", async () => { + const detail = deferred(); + handlers.set(`${appPath}/a1`, () => detail.promise); + const first = grading.getState().fetchReviews(); + await new Promise((r) => setImmediate(r)); + grading.getState().reset(); + await grading.getState().fetchReviews("r2"); + detail.resolve(ok({ id: "a1" })); + await first; + assert.equal(grading.getState().detail.id, "a2"); +}); + +test("grading cannot submit or navigate while the queue is failed or loading", async () => { + handlers.set(pendingPath, failed); + await grading.getState().fetchReviews(); + await grading.getState().submitVote("r1", "accept"); + grading.getState().navigateNext(); + assert.equal(grading.getState().currentIndex, 0); + assert.equal(requests.filter((r) => r.method === "PUT").length, 0); +}); + +test("application refresh preserves filters and sort, drops cursor, and exposes retry errors", async () => { + await applications.getState().fetchApplications({ + status: "submitted", + search: "alice", + sort_by: "reject_votes", + cursor: "page2", + }); + handlers.set(appPath, failed); + await applications.getState().fetchApplications(); + assert.equal(applications.getState().error, "Database unavailable"); + assert.equal(applications.getState().currentSearch, "alice"); + handlers.delete(appPath); + await applications.getState().fetchApplications(); + assert.equal(applications.getState().error, null); + const query = new URL(requests.at(-1).url, "http://test.local").searchParams; + assert.equal(query.get("status"), "submitted"); + assert.equal(query.get("search"), "alice"); + assert.equal(query.get("sort_by"), "reject_votes"); + assert.equal(query.get("cursor"), null); +}); + +test("application and statistics refreshes ignore stale responses", async () => { + for (const [path, action, errorKey] of [ + [appPath, "fetchApplications", "error"], + [`${appPath}/stats`, "fetchStats", "statsError"], + ]) { + const older = deferred(); + handlers.set(path, () => older.promise); + const first = applications.getState()[action](); + handlers.delete(path); + await applications.getState()[action](); + older.resolve(failed()); + await first; + assert.equal(applications.getState()[errorKey], null); + } +}); + +test("a vote response from before a queue reset cannot change the new queue", async () => { + await grading.getState().fetchReviews(); + const vote = deferred(); + handlers.set("/v1/admin/reviews/r1", () => vote.promise); + const first = grading.getState().submitVote("r1", "accept"); + grading.getState().reset(); + await grading.getState().fetchReviews("r2"); + grading.getState().setLocalNotes("New session notes"); + vote.resolve(ok({})); + await first; + assert.equal(grading.getState().reviews.length, 3); + assert.equal(grading.getState().currentIndex, 1); + assert.equal(grading.getState().localNotes, "New session notes"); +}); + +test("completing the last review invalidates its outstanding detail request", async () => { + handlers.set(pendingPath, () => ok({ reviews: [reviews[0]] })); + const detail = deferred(); + handlers.set(`${appPath}/a1`, () => detail.promise); + const first = grading.getState().fetchReviews(); + await new Promise((r) => setImmediate(r)); + await grading.getState().submitVote("r1", "accept"); + detail.resolve(ok({ id: "a1" })); + await first; + assert.equal(grading.getState().reviews.length, 0); + assert.equal(grading.getState().detail, null); + assert.equal(grading.getState().detailLoading, false); +}); diff --git a/client/portal/src/pages/admin/_shared/AppSidebar.tsx b/client/portal/src/pages/admin/_shared/AppSidebar.tsx index 15834b0c1..f1d576211 100644 --- a/client/portal/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/portal/src/pages/admin/_shared/AppSidebar.tsx @@ -11,6 +11,7 @@ import { ScanLine, Settings, Star, + Trophy, UserCheck, Users, } from "lucide-react"; @@ -66,6 +67,11 @@ const eventNav = [ url: "/admin/faq", icon: MessageSquare, }, + { + name: "Tracks", + url: "/admin/tracks", + icon: Trophy, + }, ]; const superAdminNav = [ diff --git a/client/portal/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx b/client/portal/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx index 99de8ae2c..571a90840 100644 --- a/client/portal/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx +++ b/client/portal/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx @@ -35,6 +35,8 @@ import { TimelineSection } from "./detail-sections/TimelineSection"; interface ApplicationDetailPanelProps { application: Application | null; loading: boolean; + error?: string | null; + onRetry?: () => void; open: boolean; onClose: () => void; onGrade?: () => void; @@ -47,6 +49,8 @@ interface ApplicationDetailPanelProps { export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ application, loading, + error, + onRetry, open, onClose, onGrade, @@ -159,6 +163,15 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ ))} + ) : error ? ( +
+

{error}

+ {onRetry && ( + + )} +
) : application ? (
diff --git a/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx b/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx index 7a499b2da..af170ae81 100644 --- a/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx +++ b/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx @@ -73,7 +73,7 @@ export const ApplicationsTable = memo(function ApplicationsTable({ applications.map((app) => { const name = redact ? formatApplicantLabel(app.id) - : formatName(app.first_name, app.last_name); + : formatName(app.first_name, app.last_name, app.email); const email = redact ? maskEmail(app.email) : app.email; const isSelected = selectedId === app.id; diff --git a/client/portal/src/pages/admin/all-applicants/createStore.ts b/client/portal/src/pages/admin/all-applicants/createStore.ts index 5a319f798..2ed6c10bb 100644 --- a/client/portal/src/pages/admin/all-applicants/createStore.ts +++ b/client/portal/src/pages/admin/all-applicants/createStore.ts @@ -15,6 +15,7 @@ import type { export interface ApplicationsState { applications: ApplicationListItem[]; loading: boolean; + error: string | null; nextCursor: string | null; prevCursor: string | null; hasMore: boolean; @@ -23,6 +24,7 @@ export interface ApplicationsState { currentSortBy?: ApplicationSortBy; stats: ApplicationStats | null; statsLoading: boolean; + statsError: string | null; fetchApplications: ( params?: FetchParams, signal?: AbortSignal, @@ -38,9 +40,12 @@ interface ApplicationsStoreConfig { } export function createApplicationsStore(config: ApplicationsStoreConfig) { + let fetchSequence = 0; + let statsSequence = 0; return create((set, get) => ({ applications: [], loading: false, + error: null, nextCursor: null, prevCursor: null, hasMore: false, @@ -49,9 +54,11 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { currentSortBy: config.defaultSortBy, stats: null, statsLoading: false, + statsError: null, fetchApplications: async (params?: FetchParams, signal?: AbortSignal) => { - set({ loading: true }); + const requestId = ++fetchSequence; + set({ loading: true, error: null }); let status: ApplicationStatus | null; if (params && "status" in params && params.status !== undefined) { @@ -74,6 +81,13 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { sortBy = get().currentSortBy; } + // Remember the requested view immediately so retries and assignment + // refreshes keep filters even while another fetch is pending. + set({ + currentStatus: status, + currentSearch: search, + currentSortBy: sortBy, + }); const res = await apiFetchApplications( { ...params, @@ -84,7 +98,11 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { signal, ); - if (signal?.aborted) return; + if (requestId !== fetchSequence) return; + if (signal?.aborted) { + set({ loading: false }); + return; + } if (res.status === 200 && res.data) { set({ @@ -99,6 +117,7 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { }); } else { set({ + error: res.error || "Unable to load applications. Please try again.", applications: [], nextCursor: null, prevCursor: null, @@ -109,16 +128,25 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { }, fetchStats: async (signal?: AbortSignal) => { - set({ statsLoading: true }); + const requestId = ++statsSequence; + set({ statsLoading: true, statsError: null }); const res = await fetchApplicationStats(signal); - if (signal?.aborted) return; + if (requestId !== statsSequence) return; + if (signal?.aborted) { + set({ statsLoading: false }); + return; + } if (res.status === 200 && res.data) { set({ stats: res.data, statsLoading: false }); } else { - set({ stats: null, statsLoading: false }); + set({ + stats: null, + statsLoading: false, + statsError: res.error || "Unable to load application statistics.", + }); } }, @@ -127,7 +155,10 @@ export function createApplicationsStore(config: ApplicationsStoreConfig) { }, resetPagination: () => { + ++fetchSequence; set({ + error: null, + loading: false, applications: [], nextCursor: null, prevCursor: null, diff --git a/client/portal/src/pages/admin/all-applicants/hooks/useApplicationDetail.ts b/client/portal/src/pages/admin/all-applicants/hooks/useApplicationDetail.ts index 92b60e72b..129806238 100644 --- a/client/portal/src/pages/admin/all-applicants/hooks/useApplicationDetail.ts +++ b/client/portal/src/pages/admin/all-applicants/hooks/useApplicationDetail.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { errorAlert, getRequest } from "@/shared/lib/api"; import type { Application } from "@/types"; @@ -7,6 +7,8 @@ interface UseApplicationDetailResult { detail: Application | null; loading: boolean; clear: () => void; + refresh: () => void; + error: string | null; } export function useApplicationDetail( @@ -14,6 +16,9 @@ export function useApplicationDetail( ): UseApplicationDetailResult { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const refresh = useCallback(() => setRefreshKey((key) => key + 1), []); useEffect(() => { if (!applicationId) { @@ -24,6 +29,8 @@ export function useApplicationDetail( (async () => { setLoading(true); + setDetail(null); + setError(null); const res = await getRequest( `/admin/applications/${applicationId}`, "application", @@ -34,6 +41,7 @@ export function useApplicationDetail( if (res.status === 200 && res.data) { setDetail(res.data); } else { + setError(res.error || "Unable to load application details."); errorAlert(res); } setLoading(false); @@ -42,11 +50,12 @@ export function useApplicationDetail( return () => { controller.abort(); }; - }, [applicationId]); + }, [applicationId, refreshKey]); - const clear = () => { + const clear = useCallback(() => { setDetail(null); - }; + setError(null); + }, []); - return { detail, loading, clear }; + return { detail, loading, clear, refresh, error }; } diff --git a/client/portal/src/pages/admin/all-applicants/utils.ts b/client/portal/src/pages/admin/all-applicants/utils.ts index 01ad933e5..9b1295ebe 100644 --- a/client/portal/src/pages/admin/all-applicants/utils.ts +++ b/client/portal/src/pages/admin/all-applicants/utils.ts @@ -21,10 +21,21 @@ export function getStatusColor(status: string): string { } } +/** + * Display name for an applicant, falling back to their email. + * + * Walk-ins get an application row with empty responses (see WalkInsStore), so + * first_name/last_name are null for them forever and there is no name to + * recover — without the fallback those rows read as "-" in every admin view. + * Pass the email only from non-redacted branches; redacted views use + * formatApplicantLabel/maskEmail instead. + */ export function formatName( firstName: string | null, lastName: string | null, + fallbackEmail?: string | null, ): string { - if (!firstName && !lastName) return "-"; - return `${firstName ?? ""} ${lastName ?? ""}`.trim(); + const name = `${firstName ?? ""} ${lastName ?? ""}`.trim(); + if (name) return name; + return fallbackEmail || "-"; } diff --git a/client/portal/src/pages/admin/index.ts b/client/portal/src/pages/admin/index.ts index 06c8cf924..e23c41a30 100644 --- a/client/portal/src/pages/admin/index.ts +++ b/client/portal/src/pages/admin/index.ts @@ -4,3 +4,4 @@ export { default as ReviewsPage } from "./reviews/ReviewsPage"; export { default as ScansPage } from "./scans/ScansPage"; export { default as SchedulePage } from "./schedule/SchedulePage"; export { default as SponsorsPage } from "./sponsors/SponsorsPage"; +export { default as TracksPage } from "./tracks/TracksPage"; diff --git a/client/portal/src/pages/admin/reviews/ReviewsPage.tsx b/client/portal/src/pages/admin/reviews/ReviewsPage.tsx index fc39eeed9..e4400ea72 100644 --- a/client/portal/src/pages/admin/reviews/ReviewsPage.tsx +++ b/client/portal/src/pages/admin/reviews/ReviewsPage.tsx @@ -45,7 +45,8 @@ import type { ReviewNote } from "./types"; export default function ReviewsPage() { const navigate = useNavigate(); - const { tab, reviews, loading, setTab, fetchReviews } = useReviewsStore(); + const { tab, reviews, loading, error, setTab, fetchReviews } = + useReviewsStore(); const refreshKey = refreshAssignedPage((state) => state.refreshKey); const [selectedId, setSelectedId] = useState(null); @@ -214,12 +215,13 @@ export default function ReviewsPage() { }, [tab, selectedId]); // --- Descriptions --- - const description = - tab === "assigned" ? ( - <>{filteredReviews.length} review(s) assigned to you - ) : ( - <>{filteredReviews.length} completed review(s) - ); + const description = error ? ( + <>Unable to load reviews + ) : tab === "assigned" ? ( + <>{filteredReviews.length} review(s) assigned to you + ) : ( + <>{filteredReviews.length} completed review(s) + ); // --- Header actions --- const headerActions = @@ -240,13 +242,24 @@ export default function ReviewsPage() { Grade{" "} {redact ? formatApplicantLabel(reviews[0].application_id) - : formatName(reviews[0].first_name, reviews[0].last_name)} + : formatName( + reviews[0].first_name, + reviews[0].last_name, + reviews[0].email, + )} ) : undefined; // --- Table --- - const table = ( + const table = error ? ( +
+

{error}

+ +
+ ) : ( diff --git a/client/portal/src/pages/admin/reviews/components/ReviewsTable.tsx b/client/portal/src/pages/admin/reviews/components/ReviewsTable.tsx index a200a140e..539f4682d 100644 --- a/client/portal/src/pages/admin/reviews/components/ReviewsTable.tsx +++ b/client/portal/src/pages/admin/reviews/components/ReviewsTable.tsx @@ -93,7 +93,11 @@ export const ReviewsTable = memo(function ReviewsTable({ {redact ? formatApplicantLabel(review.application_id) - : formatName(review.first_name, review.last_name)} + : formatName( + review.first_name, + review.last_name, + review.email, + )} {redact ? maskEmail(review.email) : review.email} diff --git a/client/portal/src/pages/admin/reviews/grading/GradingPage.tsx b/client/portal/src/pages/admin/reviews/grading/GradingPage.tsx index a5c9d0765..70d6a1c85 100644 --- a/client/portal/src/pages/admin/reviews/grading/GradingPage.tsx +++ b/client/portal/src/pages/admin/reviews/grading/GradingPage.tsx @@ -1,5 +1,5 @@ import { ArrowLeft } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Button } from "@/components/ui/button"; @@ -23,6 +23,7 @@ export default function GradingPage() { const reviews = useAdminGradingStore((s) => s.reviews); const loading = useAdminGradingStore((s) => s.loading); + const error = useAdminGradingStore((s) => s.error); const currentIndex = useAdminGradingStore((s) => s.currentIndex); const detail = useAdminGradingStore((s) => s.detail); const detailLoading = useAdminGradingStore((s) => s.detailLoading); @@ -32,7 +33,6 @@ export default function GradingPage() { const localNotes = useAdminGradingStore((s) => s.localNotes); const localTravelVote = useAdminGradingStore((s) => s.localTravelVote); const fetchReviews = useAdminGradingStore((s) => s.fetchReviews); - const loadDetail = useAdminGradingStore((s) => s.loadDetail); const navigateNext = useAdminGradingStore((s) => s.navigateNext); const navigatePrev = useAdminGradingStore((s) => s.navigatePrev); const submitVote = useAdminGradingStore((s) => s.submitVote); @@ -40,38 +40,39 @@ export default function GradingPage() { const setLocalTravelVote = useAdminGradingStore((s) => s.setLocalTravelVote); const reset = useAdminGradingStore((s) => s.reset); - const [aiPercent, setAiPercent] = useState(null); + const aiPercent = detail?.ai_percent ?? null; + const setAiPercent = (percent: number) => { + useAdminGradingStore.setState((state) => ({ + detail: + state.detail && state.detail.id === detail?.id + ? { ...state.detail, ai_percent: percent } + : state.detail, + })); + }; const redact = useRedactApplicants(); const currentReview = reviews[currentIndex] ?? null; - // Initialize + const targetReviewId = searchParams.get("review") ?? undefined; useEffect(() => { - const targetReviewId = searchParams.get("review"); - + const controller = new AbortController(); reset(); - fetchReviews().then(() => { - const revs = useAdminGradingStore.getState().reviews; - if (revs.length > 0) { - const targetIndex = targetReviewId - ? revs.findIndex((r) => r.id === targetReviewId) - : -1; - const idx = targetIndex >= 0 ? targetIndex : 0; - useAdminGradingStore.setState({ currentIndex: idx }); - loadDetail(revs[idx].application_id); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Sync AI percent from detail - useEffect(() => { - setAiPercent(detail?.ai_percent ?? null); - }, [detail]); + void fetchReviews(targetReviewId, controller.signal); + return () => { + controller.abort(); + reset(); + }; + }, [fetchReviews, reset, targetReviewId]); const handleVote = useCallback( (vote: ReviewVote) => { - if (currentReview && !submitting && !currentReview.vote) { + if ( + currentReview && + !loading && + !error && + !submitting && + !currentReview.vote + ) { // A travel yes/no is required when the applicant requested travel if ( currentReview.travel_status !== "not_requested" && @@ -82,11 +83,11 @@ export default function GradingPage() { submitVote(currentReview.id, vote); } }, - [currentReview, submitting, submitVote, localTravelVote], + [currentReview, loading, error, submitting, submitVote, localTravelVote], ); useGradingKeyboardShortcuts({ - disabled: submitting, + disabled: submitting || loading || !!error, canAct: !!currentReview?.id && !currentReview?.vote, escapeUrl: "/admin/reviews", onNavigateNext: navigateNext, @@ -106,7 +107,11 @@ export default function GradingPage() {

{redact ? formatApplicantLabel(currentReview.application_id) - : formatName(currentReview.first_name, currentReview.last_name)} + : formatName( + currentReview.first_name, + currentReview.last_name, + currentReview.email, + )}

@@ -116,8 +121,10 @@ export default function GradingPage() { totalCount={reviews.length} onNavigateNext={navigateNext} onNavigatePrev={navigatePrev} - canNavigatePrev={!loading && currentIndex > 0} - canNavigateNext={!loading && currentIndex < reviews.length - 1} + canNavigatePrev={!loading && !error && !submitting && currentIndex > 0} + canNavigateNext={ + !loading && !error && !submitting && currentIndex < reviews.length - 1 + } detailsPanel={ {currentReview && ( @@ -158,7 +165,17 @@ export default function GradingPage() { } emptyState={
-

No pending reviews to grade.

+

+ {error || "No pending reviews to grade."} +

+ {error && ( + + )} + {logoFile && ( + + )} +
+
+

+ PNG, JPEG, WebP, or GIF (max 750KB) +

+ + +
+ + setTitle(e.target.value)} + placeholder="Best Financial Hack" + required + /> +
+ +
+ + setSponsorName(e.target.value)} + placeholder="Capital One" + /> +
+ +
+ +