From 35ecaf0e6669f48d72a77abe685ebfe8ada8526c Mon Sep 17 00:00:00 2001 From: jeromehardaway Date: Wed, 13 May 2026 09:19:50 -0400 Subject: [PATCH 1/2] feat(api): Rate-limit J0dI3 proxy, form endpoints, AI chat, and health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1064. Extends src/lib/rate-limit.ts with applyRateLimit() — a small wrapper that sets X-RateLimit-* headers (and Retry-After on block) so each route opts in with one line. Applied limits: - /api/j0di3/* (per-user) 30 / 60s — every call hits a paid AI - /api/ai/chat (per-user) 20 / 60s — same reason, streaming - /api/contact (per-IP) 5 / 15m — paired with Gemini classifier - /api/newsletter (per-IP) 5 / 15m - /api/apply (per-IP) 3 / 60m - /api/mentor (per-IP) 5 / 60m - /api/mentee (per-IP) 5 / 60m - /api/health (per-IP) 60 / 60s — caps tight-loop pings Limits documented in docs/RATE_LIMITS.md (response shape, frontend conventions, how to add a new limit). Stayed on the existing in-memory bucketing — note in the doc that Redis/Upstash is the swap when we leave single-instance Vercel. Test fixtures for j0di3-proxy, /api/contact, and /api/health updated to include req.headers / req.socket / res.setHeader, and reset the bucket map via _resetRateLimitForTests in beforeEach so test order does not accumulate state. --- __tests__/api/health.test.ts | 9 +++- __tests__/lib/j0di3/j0di3-proxy.test.ts | 3 ++ __tests__/pages/api/contact.test.ts | 9 +++- docs/RATE_LIMITS.md | 67 +++++++++++++++++++++++++ src/lib/j0di3-proxy.ts | 33 ++++++++---- src/lib/rate-limit.ts | 46 ++++++++++++++++- src/pages/api/ai/chat.ts | 15 ++++++ src/pages/api/apply.ts | 13 +++++ src/pages/api/contact.ts | 10 ++++ src/pages/api/health.ts | 9 ++++ src/pages/api/mentee.ts | 13 +++++ src/pages/api/mentor.ts | 13 +++++ src/pages/api/newsletter.ts | 9 ++++ 13 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 docs/RATE_LIMITS.md diff --git a/__tests__/api/health.test.ts b/__tests__/api/health.test.ts index 6c8ff3f51..460fac4e6 100644 --- a/__tests__/api/health.test.ts +++ b/__tests__/api/health.test.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import type { Mock } from "vitest"; import handler from "@/pages/api/health"; +import { _resetRateLimitForTests } from "@/lib/rate-limit"; vi.mock("@/lib/prisma", () => ({ default: { @@ -16,10 +17,15 @@ function createMockReqRes(method = "GET"): { req: NextApiRequest; res: NextApiResponse; } { - const req = { method } as NextApiRequest; + const req = { + method, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; } @@ -28,6 +34,7 @@ describe("GET /api/health", () => { const originalEnv = process.env; beforeEach(() => { + _resetRateLimitForTests(); process.env = { ...originalEnv, DATABASE_URL: "postgresql://localhost:5432/test", diff --git a/__tests__/lib/j0di3/j0di3-proxy.test.ts b/__tests__/lib/j0di3/j0di3-proxy.test.ts index d08812724..18c2ec433 100644 --- a/__tests__/lib/j0di3/j0di3-proxy.test.ts +++ b/__tests__/lib/j0di3/j0di3-proxy.test.ts @@ -29,6 +29,8 @@ function createMockReqRes( method: "POST", body: { question: "What is React?" }, query: {}, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, user: { id: "user-123", name: "Test", @@ -42,6 +44,7 @@ function createMockReqRes( const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; diff --git a/__tests__/pages/api/contact.test.ts b/__tests__/pages/api/contact.test.ts index be79663af..395708085 100644 --- a/__tests__/pages/api/contact.test.ts +++ b/__tests__/pages/api/contact.test.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import type { Mock } from "vitest"; import { classifyContact } from "@/pages/api/api-helpers/classify-contact"; import handler from "@/pages/api/contact"; +import { _resetRateLimitForTests } from "@/lib/rate-limit"; vi.mock("@/pages/api/api-helpers/classify-contact", () => ({ classifyContact: vi.fn(), @@ -14,16 +15,22 @@ function createMockReqRes(body: Record): { req: NextApiRequest; res: NextApiResponse; } { - const req = { body } as NextApiRequest; + const req = { + body, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; } describe("POST /api/contact", () => { beforeEach(() => { + _resetRateLimitForTests(); process.env.CONTACT_WEBHOOK_ID = "T00/B00/xxx"; }); diff --git a/docs/RATE_LIMITS.md b/docs/RATE_LIMITS.md new file mode 100644 index 000000000..c1916add4 --- /dev/null +++ b/docs/RATE_LIMITS.md @@ -0,0 +1,67 @@ +# API rate limits + +Every public API route that touches a paid backend, sends a Slack notification, +or is otherwise expensive enforces a per-IP or per-user rate limit. The limiter +lives at `src/lib/rate-limit.ts` and is in-memory (single Vercel instance); +swap the underlying `Map` for Redis/Upstash when we scale out. + +## Response headers + +Every rate-limited route sets: + +- `X-RateLimit-Limit` — max requests in the window +- `X-RateLimit-Remaining` — requests left in the current window +- `X-RateLimit-Reset` — UNIX seconds when the window resets +- `Retry-After` — seconds until the window resets (sent **only on 429**) + +The body on `429` is `{ "error": "" }` so the frontend can +surface the reason directly. + +## Per-route limits + +| Route | Bucket scope | Key | Limit / window | +| ---------------------------------- | ------------------- | ------------------ | ---------------------- | +| `/api/j0di3/*` | `j0di3` | session user id | **30 / minute** | +| `/api/ai/chat` | `ai-chat` | session user id | **20 / minute** | +| `/api/military-resume/translate` | (default) | IP | **10 / 15 minutes** | +| `/api/military-resume/parse-pdf` | (default) | IP | _per-route, see file_ | +| `/api/military-resume/extract-fields` | (default) | IP | _per-route, see file_ | +| `/api/military-resume/salary-lookup` | (default) | IP | _per-route, see file_ | +| `/api/contact` | `contact` | IP | **5 / 15 minutes** | +| `/api/newsletter` | `newsletter` | IP | **5 / 15 minutes** | +| `/api/apply` | `apply` | IP | **3 / hour** | +| `/api/mentor` | `mentor` | IP | **5 / hour** | +| `/api/mentee` | `mentee` | IP | **5 / hour** | +| `/api/health` | `health` | IP | **60 / minute** | + +Limits are intentionally conservative — they should never block normal use but +will catch a buggy client in a tight loop or a casual abuser. If a real user +hits one, raise the cap in code; do not paper over with a longer window. + +## Frontend conventions + +- Treat `429` as user-facing: show the response body's `error` string verbatim. +- If `Retry-After` is present, disable the affected control for that many + seconds before letting the user retry. +- Do not implement client-side retries on `429` — the user should consciously + retry, otherwise we just amplify the storm. + +## Adding a new limit + +```ts +import { applyRateLimit } from "@/lib/rate-limit"; + +const rl = applyRateLimit(req, res, { + scope: "my-feature", // unique per route family + key: session.user.id, // optional — defaults to client IP + max: 10, + windowMs: 60_000, +}); +if (!rl.allowed) { + return res.status(429).json({ error: "Slow down a little." }); +} +``` + +`applyRateLimit` sets the headers above automatically — the caller only has to +short-circuit on `!rl.allowed`. Don't pull `checkRateLimit` directly unless you +need a result without writing headers (e.g. for tests). diff --git a/src/lib/j0di3-proxy.ts b/src/lib/j0di3-proxy.ts index 5e55b7035..76393ed4e 100644 --- a/src/lib/j0di3-proxy.ts +++ b/src/lib/j0di3-proxy.ts @@ -1,16 +1,30 @@ import axios from "axios"; import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth, type AuthenticatedRequest } from "@/lib/rbac"; import j0di3 from "@/lib/j0di3-client"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { type AuthenticatedRequest, requireAuth } from "@/lib/rbac"; type Method = "GET" | "POST" | "PATCH" | "DELETE"; -export function j0di3Proxy( - method: Method, - path: string | ((req: NextApiRequest) => string) -) { +export function j0di3Proxy(method: Method, path: string | ((req: NextApiRequest) => string)) { return requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => { const url = typeof path === "function" ? path(req) : path; + + // Per-user rate limit. Every call hits a paid AI backend, so a + // misbehaving client or stolen session should not be able to drive + // cost up arbitrarily. + const rl = applyRateLimit(req, res, { + scope: "j0di3", + key: req.user!.id, + max: 30, + windowMs: 60_000, + }); + if (!rl.allowed) { + return res.status(429).json({ + error: "You're making requests to J0dI3 too quickly. Please wait a moment and try again.", + }); + } + const troopId = req.user!.troopId; if (!troopId) { @@ -20,8 +34,7 @@ export function j0di3Proxy( } try { - const body = - method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; + const body = method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; const { data } = await j0di3({ method, @@ -30,12 +43,14 @@ export function j0di3Proxy( params: method === "GET" ? { ...req.query, troop_id: troopId } : undefined, }); - res.json(data); } catch (error: unknown) { if (axios.isAxiosError(error) && error.response) { const status = error.response.status; - const message = error.response.data?.detail || error.response.data?.error || "J0dI3 request failed"; + const message = + error.response.data?.detail || + error.response.data?.error || + "J0dI3 request failed"; return res.status(status).json({ error: message }); } console.error("[j0di3-proxy] Unexpected error:", error); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 89807af42..2cab8c1fb 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,7 +5,7 @@ * swap the Map for Redis or similar. */ -import type { NextApiRequest } from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; interface RateLimitEntry { count: number; @@ -70,3 +70,47 @@ export function checkRateLimit( entry.count++; return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }; } + +export interface ApplyRateLimitOptions { + /** Bucket key. Defaults to client IP. Use a user/session id for per-user limits. */ + key?: string; + /** Max requests allowed in the window. */ + max: number; + /** Window length in milliseconds. */ + windowMs: number; + /** Optional prefix added to the key (avoids collisions across endpoints sharing a key strategy). */ + scope?: string; +} + +/** + * Apply a rate limit and set the standard headers (X-RateLimit-* and + * Retry-After when blocked). The caller is responsible for short-circuiting + * with `res.status(429).json(...)` when `allowed === false`. + */ +export function applyRateLimit( + req: NextApiRequest, + res: NextApiResponse, + options: ApplyRateLimitOptions +): RateLimitResult { + const rawKey = options.key || getClientIp(req); + const bucketKey = options.scope ? `${options.scope}:${rawKey}` : rawKey; + const limit = checkRateLimit(bucketKey, options.max, options.windowMs); + + res.setHeader("X-RateLimit-Limit", options.max); + res.setHeader("X-RateLimit-Remaining", limit.remaining); + res.setHeader("X-RateLimit-Reset", Math.ceil(limit.resetAt / 1000)); + if (!limit.allowed) { + const retryAfterSec = Math.max(0, Math.ceil((limit.resetAt - Date.now()) / 1000)); + res.setHeader("Retry-After", retryAfterSec); + } + return limit; +} + +/** + * Exposed for unit tests — clears the in-memory bucket so test order does not + * accumulate state. Do not call from production code. + */ +export function _resetRateLimitForTests(): void { + ipBuckets.clear(); + lastCleanup = Date.now(); +} diff --git a/src/pages/api/ai/chat.ts b/src/pages/api/ai/chat.ts index 4807a2873..a92656a98 100644 --- a/src/pages/api/ai/chat.ts +++ b/src/pages/api/ai/chat.ts @@ -1,6 +1,7 @@ import { streamText } from "ai"; import { NextApiResponse } from "next"; import { getAIModelWithFallback, JODIE_SYSTEM_PROMPT } from "@/lib/ai-provider"; +import { applyRateLimit } from "@/lib/rate-limit"; import { AuthenticatedRequest, requireAuth } from "@/lib/rbac"; export const runtime = "nodejs"; @@ -47,6 +48,20 @@ export default requireAuth(async (req: AuthenticatedRequest, res: NextApiRespons return res.status(405).json({ error: "Method not allowed" }); } + // Per-user rate limit on the streaming AI endpoint. Each call hits a paid + // model, so a runaway client should not be able to drain quota. + const rl = applyRateLimit(req, res, { + scope: "ai-chat", + key: req.user!.id, + max: 20, + windowMs: 60_000, + }); + if (!rl.allowed) { + return res.status(429).json({ + error: "You're sending messages too quickly. Please wait a moment.", + }); + } + try { const { messages, lessonContext } = req.body as ChatRequestBody; diff --git a/src/pages/api/apply.ts b/src/pages/api/apply.ts index 189e132c8..e3bac45cd 100644 --- a/src/pages/api/apply.ts +++ b/src/pages/api/apply.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody interface to type-check the request body @@ -112,6 +114,17 @@ async function postToSlack(text: string): Promise { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "apply", max: 3, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many application attempts. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; const hasErrors = validateBody(parsedBody); diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 2a13f8bcf..92af791fc 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -1,5 +1,6 @@ import axios, { AxiosRequestConfig } from "axios"; import { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkLength, checkParams, contactErrors } from "./api-helpers"; import { classifyContact } from "./api-helpers/classify-contact"; @@ -35,6 +36,15 @@ async function postToSlack(parsedBody: ParsedBody): Promise { } export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Per-IP rate limit on the contact form. The Gemini classifier handles + // semantic spam; this caps the volume that classifier even has to score. + const rl = applyRateLimit(req, res, { scope: "contact", max: 5, windowMs: 15 * 60 * 1000 }); + if (!rl.allowed) { + return res.status(429).json({ + error: "Too many messages sent recently. Please try again in a few minutes.", + }); + } + const parsedBody: ParsedBody = req.body as ParsedBody; const { name, email, message } = parsedBody; const requiredParams: string[] = ["email", "message"]; diff --git a/src/pages/api/health.ts b/src/pages/api/health.ts index 91ba0396b..1f4f9b93e 100644 --- a/src/pages/api/health.ts +++ b/src/pages/api/health.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "@/lib/prisma"; +import { applyRateLimit } from "@/lib/rate-limit"; import { version } from "../../../package.json"; interface CheckResult { @@ -64,6 +65,14 @@ export default async function handler( return res.status(405).json({ error: "Method not allowed" }); } + // Cap /health to prevent tight-loop pings from a misbehaving uptime + // checker. Real uptime probes ping every 30–60s — 60/min per IP is + // generous. + const rl = applyRateLimit(req, res, { scope: "health", max: 60, windowMs: 60_000 }); + if (!rl.allowed) { + return res.status(429).json({ error: "Rate limit exceeded" }); + } + const dbCheck = await checkDatabase(); const envCheck = checkEnvironment(); const checks = [dbCheck, envCheck]; diff --git a/src/pages/api/mentee.ts b/src/pages/api/mentee.ts index 9505cdbd0..c6c2ea6d3 100644 --- a/src/pages/api/mentee.ts +++ b/src/pages/api/mentee.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody type for mentee @@ -16,6 +18,17 @@ interface ParsedBody { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "mentee", max: 5, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many mentee signups. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; // Cast to ensure body matches ParsedBody interface const requiredParams: (keyof ParsedBody)[] = [ diff --git a/src/pages/api/mentor.ts b/src/pages/api/mentor.ts index c1ef8d37b..574341b91 100644 --- a/src/pages/api/mentor.ts +++ b/src/pages/api/mentor.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody type @@ -15,6 +17,17 @@ interface ParsedBody { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "mentor", max: 5, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many mentor signups. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; // Cast to ensure body matches ParsedBody interface const requiredParams: (keyof ParsedBody)[] = [ "name", diff --git a/src/pages/api/newsletter.ts b/src/pages/api/newsletter.ts index 5e8047897..28409fd29 100644 --- a/src/pages/api/newsletter.ts +++ b/src/pages/api/newsletter.ts @@ -1,4 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; const MAILCHIMP_API_SERVER = "https://us4.api.mailchimp.com/3.0/lists"; const MAILCHIMP_LIST_ID = process.env?.MAILCHIMP_LIST_ID ?? ""; @@ -43,6 +44,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method !== "POST") { return res.status(405).json({ error: "Method Not Allowed" }); } + + const rl = applyRateLimit(req, res, { scope: "newsletter", max: 5, windowMs: 15 * 60 * 1000 }); + if (!rl.allowed) { + return res.status(429).json({ + error: "Too many subscription attempts. Please try again in a few minutes.", + }); + } + let requestData: RequestData; try { From 6ec2324721efc4e29813c61c11e473ca3345efba Mon Sep 17 00:00:00 2001 From: jeromehardaway Date: Wed, 13 May 2026 09:52:25 -0400 Subject: [PATCH 2/2] fix(client): Replace silent catch blocks across fetch handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1058. Adds src/utils/handle-client-error.ts with handleClientError() and getErrorMessage() — the single helper every silent catch should be migrated to. handleClientError always logs (so failures are no longer invisible) and has a TODO marker where Sentry / equivalent will plug in once we wire observability. Migrated every silent catch the issue called out plus a few near- misses found by walking the AST: - src/pages/challenges/index.tsx — fetchRecommended, fetchHistory, handleGetHint, handleShowSolution. Added per-section error states rendered inline so the user sees what failed instead of a hung spinner. - src/pages/challenges/[id].tsx — handleGetHint, handleShowSolution. Pipe failures into the existing `error` banner. - src/pages/challenges/browse.tsx — fetchTopics. Topic chips were legitimately a soft-fail; now logged for devs even though UX is unchanged. - src/components/profile/TroopDashboard.tsx — handleModuleChange, fetchDashboard, fetchWarmups, fetchProgress. Module change now surfaces a user-visible error. - src/components/jobs/ResumeScorer.tsx — handleFileUpload, handleDrop. Both already had hook-level error reporting; the helper just makes the underlying failure visible in the console / future observability sink. - src/components/translator/TranslatorForm.tsx — fetchDescription, PDF extract-fields fallback, PDF upload. Same hook pattern. - src/containers/profile/bio.tsx — fetchProfile, handleSave. Save now surfaces an inline banner on failure (it used to silently drop on the floor). Biome's `noEmptyBlockStatements` rule is already `error` for src/ and was being satisfied by `// non-critical` style comments inside the catch — that's why these slipped through. Documented this in the helper's JSDoc so future reviewers know what to insist on. Most of the line-count delta in JSX-heavy files is Biome auto-reformat on save (long lines wrapped to 100 cols); diff with --ignore-all-space to see the semantic changes only. --- src/components/jobs/ResumeScorer.tsx | 751 +++++++++++++++---- src/components/profile/TroopDashboard.tsx | 96 ++- src/components/translator/TranslatorForm.tsx | 111 +-- src/containers/profile/bio.tsx | 37 +- src/pages/challenges/[id].tsx | 128 ++-- src/pages/challenges/browse.tsx | 17 +- src/pages/challenges/index.tsx | 249 +++--- src/utils/handle-client-error.ts | 46 ++ 8 files changed, 1014 insertions(+), 421 deletions(-) create mode 100644 src/utils/handle-client-error.ts diff --git a/src/components/jobs/ResumeScorer.tsx b/src/components/jobs/ResumeScorer.tsx index 3b2c093e2..9ea8d4a1b 100644 --- a/src/components/jobs/ResumeScorer.tsx +++ b/src/components/jobs/ResumeScorer.tsx @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from "react"; import usePdfUpload from "@/hooks/use-pdf-upload"; +import { handleClientError } from "@/utils/handle-client-error"; const TARGET_ROLES = [ "Junior Software Engineer", @@ -111,7 +112,13 @@ function renderText(val: unknown): string { if (val && typeof val === "object") { // Try common field names const obj = val as Record; - return obj.name as string || obj.skill as string || obj.text as string || obj.label as string || JSON.stringify(val); + return ( + (obj.name as string) || + (obj.skill as string) || + (obj.text as string) || + (obj.label as string) || + JSON.stringify(val) + ); } return String(val); } @@ -134,32 +141,50 @@ export default function ResumeScorer() { const [error, setError] = useState(null); const fileInputRef = useRef(null); - const { upload, uploading, error: uploadError, progress, fileName, reset: resetUpload } = usePdfUpload(); + const { + upload, + uploading, + error: uploadError, + progress, + fileName, + reset: resetUpload, + } = usePdfUpload(); - const handleFileUpload = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - try { - const text = await upload(file); - setResumeText(text); - } catch { - // handled by hook - } - }, [upload]); + const handleFileUpload = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await upload(file); + setResumeText(text); + } catch (err) { + // usePdfUpload surfaces the error via `uploadError`, which the UI + // renders below. Log so devs see it in the console too. + handleClientError(err, { context: "ResumeScorer:handleFileUpload" }); + } + }, + [upload] + ); const handleTextFileRead = useCallback((file: File) => { const reader = new FileReader(); - reader.onload = (e) => setResumeText(e.target?.result as string || ""); + reader.onload = (e) => setResumeText((e.target?.result as string) || ""); reader.readAsText(file); }, []); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (!file) return; - if (file.type === "text/plain") handleTextFileRead(file); - else upload(file).then(setResumeText).catch(() => {}); - }, [upload, handleTextFileRead]); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (!file) return; + if (file.type === "text/plain") handleTextFileRead(file); + else + upload(file) + .then(setResumeText) + .catch((err) => handleClientError(err, { context: "ResumeScorer:handleDrop" })); + }, + [upload, handleTextFileRead] + ); const handleScore = async () => { if (!resumeText.trim()) return; @@ -262,7 +287,6 @@ export default function ResumeScorer() { URL.revokeObjectURL(url); }; - const dimColor = (score: number) => { if (score >= 80) return "tw-bg-green-500"; if (score >= 60) return "tw-bg-yellow-500"; @@ -278,7 +302,9 @@ export default function ResumeScorer() { }; const passFailColor = (pf: string) => { - return pf === "PASS" ? "tw-bg-green-100 tw-text-green-800" : "tw-bg-red-100 tw-text-red-800"; + return pf === "PASS" + ? "tw-bg-green-100 tw-text-green-800" + : "tw-bg-red-100 tw-text-red-800"; }; const DIMENSION_LABELS: Record = { @@ -295,7 +321,10 @@ export default function ResumeScorer() { text .replace(/harvard\s*format(ting|ted)?/gi, "ATS/recruiter formatting") .replace(/harvard/gi, "ATS/recruiter") - .replace(/Please check formatting\.?/gi, "Try pasting your resume text directly for best results.") + .replace( + /Please check formatting\.?/gi, + "Try pasting your resume text directly for best results." + ) .replace(/Could not be analyzed\.?/gi, "We had trouble reading this resume.") .replace(/Could not analyze/gi, "Unable to evaluate"); @@ -316,43 +345,87 @@ export default function ResumeScorer() { className="tw-mb-3 tw-border-2 tw-border-dashed tw-border-navy/10 tw-rounded-lg tw-p-4 tw-text-center tw-cursor-pointer hover:tw-border-primary tw-transition-colors" onClick={() => fileInputRef.current?.click()} > - + {uploading ? (
-

Parsing {fileName}... {progress}%

+

+ Parsing {fileName}... {progress}% +

-
+
) : fileName && resumeText ? (
-

{fileName} loaded

- +

+ {fileName} loaded +

+
) : (
-

Drop PDF, DOCX, or TXT here — or click to browse

+

+ Drop PDF, DOCX, or TXT here — or click to browse +

Max 5MB

)} - {uploadError &&

{uploadError}

} + {uploadError && ( +

{uploadError}

+ )}
-