Skip to content

feat: add AI ATS suggestion to LaTeX resume workflow#335

Open
AishwaryJhunjhunwala wants to merge 1 commit into
Sachinchaurasiya360:mainfrom
AishwaryJhunjhunwala:feat/ats-apply-suggestions
Open

feat: add AI ATS suggestion to LaTeX resume workflow#335
AishwaryJhunjhunwala wants to merge 1 commit into
Sachinchaurasiya360:mainfrom
AishwaryJhunjhunwala:feat/ats-apply-suggestions

Conversation

@AishwaryJhunjhunwala
Copy link
Copy Markdown

@AishwaryJhunjhunwala AishwaryJhunjhunwala commented May 19, 2026

Fixes #57

Summary

This PR bridges the gap between the ATS analysis and the resume editing workflow by allowing students to automatically apply AI-generated ATS suggestions to their resume. Previously, students had to manually switch tabs and rewrite their resume based on the ATS results. Now, they can select specific suggestions and have Gemini rewrite their LaTeX resume directly, prefilling the editor with the improved draft.

Changes

  • Backend: Added POST /api/ats/apply-suggestions endpoint in ats.routes.ts.
  • Backend: Implemented prompt building and XML <reply>/<latex> parsing logic in ats.service.ts to instruct Gemini to apply specific suggestions to the user's uploaded LaTeX resume.
  • Backend: Secured the new endpoint with isPremiumUser gating and usageLimit("GENERATE_RESUME") tracking in ats.controller.ts.
  • Frontend: Updated AtsScorePage.tsx to replace the static suggestions list with a selectable checklist (including a "Select All" toggle) and added a primary CTA button to apply the selected suggestions.
  • Frontend: Updated LatexResumeEditor.tsx to consume the initial AI-improved LaTeX draft via react-router's location.state and display a subtle notification banner reminding the user to review the changes.

How to test

  1. Log in with a Premium student account (or temporarily set your local seeded student account to MONTHLY/ACTIVE in the database).
  2. Navigate to the ATS Analyzer and upload a sample PDF resume to get an ATS score.
  3. Once the analysis completes, go to the Suggestions tab.
  4. Select one or more suggestions using the checkboxes, and click Apply suggestions & improve resume.
  5. Verify that you are redirected to the LaTeX editor and that the code is pre-filled with the AI's applied changes.
  6. Verify that a green banner appears at the top of the editor.
  7. Optional: Log in with a free-tier student account (like aarav@example.com) and verify that clicking the button returns a Premium required error.

Screenshots

Screenshot 2026-05-19 125347 Screenshot 2026-05-19 125502 Screenshot 2026-05-19 125518 Screenshot 2026-05-19 111506

Summary by CodeRabbit

  • New Features
    • Select multiple ATS suggestions via checkboxes with "Select all" control and selection counter
    • "Apply suggestions & improve resume" button applies selected improvements to your resume
    • Visual banner confirms when suggestions have been successfully applied
    • Improved error messaging for AI usage limits

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

This PR implements the "Apply ATS Suggestions" feature, enabling students to select AI-generated resume improvements and apply them directly. The flow spans server-side suggestion application via AI (with premium gating and usage tracking), client-side multi-select UI for individual suggestions, and seamless navigation to a prefilled LaTeX editor with a visual confirmation banner.

Changes

ATS Apply Suggestions Workflow

Layer / File(s) Summary
Type contracts and validation
server/src/module/ats/ats.types.ts, server/src/module/ats/ats.validation.ts
New ApplySuggestionsInput type extends resume scoring inputs with required suggestions: string[] field; corresponding Zod schema enforces non-empty suggestion array.
Service implementation for applying suggestions
server/src/module/ats/ats.service.ts
applySuggestions method validates resume ownership, extracts PDF text, constructs an ATS-optimizer LaTeX rewrite prompt, calls the AI provider, and parses <reply> and <latex> XML response tags with code fence cleanup and fallbacks.
Server controller and routing
server/src/module/ats/ats.controller.ts, server/src/module/ats/ats.routes.ts
New applySuggestions controller enforces authentication and premium subscription, validates input, invokes service, logs usage, and maps targeted HTTP errors (429 rate limit, 400 extraction failure, 404 missing resume). Route wires POST /apply-suggestions with usage limit middleware.
Client selection UI and apply mutation
client/src/module/student/ats/AtsScorePage.tsx
Adds selectedSuggestions state and applyMutation that POSTs selected indices to /ats/apply-suggestions and navigates to LaTeX editor with updatedLatex in route state; renders checkbox-driven multi-select with "Select all", suggestion count, and "Apply suggestions & improve resume" button (disabled until selection non-empty, showing loading state during apply); clears selections on re-analyze; special-case handling for 429 errors.
LaTeX editor consumption with prefilled content
client/src/module/student/ats/LatexResumeEditor.tsx
Extended template initialization to prioritize location.state?.initialLatex over query-param template; clears browser history state on mount when prefilled content is provided; renders animated banner with Wand2 icon when location.state?.banner is present.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AtsController
  participant AtsService
  participant ATS_AI
  participant UsageLog
  Client->>AtsController: POST /apply-suggestions
  Note over AtsController: Auth & Premium check
  AtsController->>AtsService: applySuggestions(studentId, input)
  AtsService->>ATS_AI: LaTeX rewrite prompt<br/>with suggestions
  ATS_AI-->>AtsService: reply & latex tags
  AtsService->>UsageLog: Log GENERATE_RESUME
  AtsService-->>AtsController: {reply, updatedLatex}
  AtsController-->>Client: 200 JSON response
Loading
sequenceDiagram
  participant UI as Selection UI
  participant Mutation as applyMutation
  participant API as /apply-suggestions
  participant Editor as LaTeX Editor
  UI->>Mutation: Click "Apply suggestions"
  Mutation->>API: POST selectedIndices
  alt Success
    API-->>Mutation: {updatedLatex, reply}
    Mutation->>Editor: Navigate with initialLatex state
    Editor-->>UI: Show banner & prefilled editor
  else 429 Rate Limit
    Mutation->>UI: Toast "AI usage limit reached"
  else Other Error
    Mutation->>UI: Toast backend error message
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • #138: Implements the same code-level flow: applying ATS suggestions by navigating to the LaTeX editor with prefilled content and enforcing premium access.

Suggested labels

enhancement, level:intermediate, gssoc: intermediate

Poem

🐰 Suggestions were whispers, now they're actions bright,
Select what matters, watch the LaTeX ignite!
From insights to drafts with just a single click—
The ATS loop closes, smooth and quick.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding AI ATS suggestion application to the LaTeX resume workflow, matching the PR's core functionality.
Linked Issues check ✅ Passed The PR implementation fully addresses issue #57 requirements: backend endpoint with Gemini integration, premium/usage gating, client-side suggestion selection, LaTeX editor routing with state, and review banner.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the apply-suggestions workflow; no unrelated modifications detected across frontend, backend routing, validation, or service layers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/module/student/ats/LatexResumeEditor.tsx`:
- Around line 155-157: In LatexResumeEditor, avoid calling
window.history.replaceState when location.state?.initialLatex is present;
instead use React Router's navigation API (e.g. the navigate function from
useNavigate) to replace the entry so Router keeps its metadata (key, idx, usr).
Replace the direct call in the if (location.state?.initialLatex) block with a
call that navigates to the same path with replace: true and an updated state
object that removes only the initialLatex flag while preserving other
location.state properties (merge existing state minus initialLatex) so Router's
internal state remains consistent.

In `@server/src/module/ats/ats.controller.ts`:
- Around line 93-109: The catch block in ats.controller.ts currently only maps
"429", "Could not extract", and "ENOENT" to 4xx; extend that block to map other
service-domain/user errors to 4xx by inspecting err.message and err.name and
returning the appropriate status and message via res (e.g., if err.message or
err.name includes "ValidationError", "Invalid", "BadRequest" =>
res.status(400).json({ message: err.message }); if it includes "Unauthorized" or
"401" => res.status(401)...; if it includes "Forbidden", "Ownership", or "403"
=> res.status(403)...; if it includes "NotFound" or "404" =>
res.status(404)...). Keep existing checks for "429", "Could not extract", and
"ENOENT", and ensure all 4xx responses include the original err.message for
client diagnostics; fall back to res.status(500) only for unexpected errors.

In `@server/src/module/ats/ats.service.ts`:
- Around line 102-104: The current isOwned check wrongly treats any parseable S3
URL as owned; instead parse the S3 key with getS3KeyFromUrl(input.resumeUrl) and
verify that that exact key (or the canonical resume URL) is associated with the
current user (e.g., compare against user.resumes or user.resumeKeys), only
granting ownership if user.resumes.includes(parsedKey) or an equivalent
user-owned identifier matches; update the isOwned logic to use the parsed key
and explicit membership check rather than just nullity.

In `@server/src/module/ats/ats.validation.ts`:
- Around line 15-17: The applySuggestionsSchema currently allows an unbounded
suggestions array which can be abused; update the schema (applySuggestionsSchema
which extends scoreResumeSchema and defines suggestions) to enforce a reasonable
maximum number of items (e.g., .max(...)) and a maximum string length per
suggestion (use z.string().max(...)) and keep the existing .min(1). Also
consider trimming/normalizing inputs (e.g., .trim() or a transform) to avoid
whitespace abuse and include clear error messages for both the array and item
limits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2f21d7d4-98f4-4116-a890-1bbaf4ed83cb

📥 Commits

Reviewing files that changed from the base of the PR and between 3552efa and 2fce3ea.

📒 Files selected for processing (7)
  • client/src/module/student/ats/AtsScorePage.tsx
  • client/src/module/student/ats/LatexResumeEditor.tsx
  • server/src/module/ats/ats.controller.ts
  • server/src/module/ats/ats.routes.ts
  • server/src/module/ats/ats.service.ts
  • server/src/module/ats/ats.types.ts
  • server/src/module/ats/ats.validation.ts

Comment on lines +155 to +157
if (location.state?.initialLatex) {
window.history.replaceState({}, document.title);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In React Router v7, what is the recommended way to clear location.stateafter navigation? Is directly callingwindow.history.replaceState({}, document.title) safe, or can it break router-managed history metadata (such as key/idx/usr)?

💡 Result:

In React Router v7, the recommended way to clear location.state is to use the navigate function returned by the useNavigate hook to perform a navigation to the current location with the replace: true option and an empty or modified state object [1][2][3]. The idiomatic approach is: const location = useLocation; const navigate = useNavigate; // To clear the state entirely navigate(location.pathname, { replace: true, state: {} }); // Or, to preserve the current URL (including search/hash) while clearing state navigate(location, { replace: true, state: undefined }); Directly calling window.history.replaceState({}, document.title) is generally discouraged and potentially unsafe when using React Router [2]. React Router manages its own internal history stack, which includes metadata such as key, idx, and usr (user-provided state) [4][5]. By bypassing the router and manipulating the browser's history API directly, you risk desynchronizing the router's internal state from the actual browser history [4]. This can lead to bugs in features that rely on this metadata, such as: 1. Navigation blocking and history stack tracking [4]. 2. Correct identification of "back" vs "forward" navigation [4]. 3. Proper handling of location.key for caching or state restoration [5]. If you need to clear state, always use the router's provided navigation methods to ensure the internal history stack remains consistent [1][2].

Citations:


Replace window.history.replaceState with router navigation to avoid desynchronizing React Router's internal state.

Calling window.history.replaceState({}, document.title) directly bypasses React Router's state management and strips critical metadata (key, idx, usr) that the router tracks. This can break navigation features like history blocking and location.key caching.

Suggested fix
 export default function LatexResumeEditor() {
   const location = useLocation();
+  const navigate = useNavigate();
   const [searchParams, setSearchParams] = useSearchParams();
   const templateId = searchParams.get("template");
@@
   useEffect(() => {
     if (templateId) setSearchParams({}, { replace: true });
     if (location.state?.initialLatex) {
-      window.history.replaceState({}, document.title);
+      navigate(".", { replace: true, state: null });
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
@@
-  const navigate = useNavigate();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/module/student/ats/LatexResumeEditor.tsx` around lines 155 - 157,
In LatexResumeEditor, avoid calling window.history.replaceState when
location.state?.initialLatex is present; instead use React Router's navigation
API (e.g. the navigate function from useNavigate) to replace the entry so Router
keeps its metadata (key, idx, usr). Replace the direct call in the if
(location.state?.initialLatex) block with a call that navigates to the same path
with replace: true and an updated state object that removes only the
initialLatex flag while preserving other location.state properties (merge
existing state minus initialLatex) so Router's internal state remains
consistent.

Comment on lines +93 to +109
if (err instanceof Error) {
if (err.message.includes("429")) {
res.status(429).json({ message: "AI usage limit reached. Please try again later." });
return;
}
if (err.message.includes("Could not extract")) {
res.status(400).json({ message: err.message });
return;
}
if (err.message.includes("ENOENT")) {
res.status(404).json({ message: "Resume file not found. Please upload again." });
return;
}
}

res.status(500).json({ message: "Failed to improve resume. Please try again." });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Map known service-domain errors to 4xx instead of generic 500.

Known user/input/ownership failures from the service currently become 500, which is misleading for clients and monitoring.

🧭 Suggested error mapping additions
       if (err instanceof Error) {
+        if (err.message.includes("Resume does not belong")) {
+          res.status(403).json({ message: err.message });
+          return;
+        }
+        if (err.message.includes("User not found")) {
+          res.status(401).json({ message: err.message });
+          return;
+        }
+        if (err.message.includes("Invalid resume URL format")) {
+          res.status(400).json({ message: err.message });
+          return;
+        }
         if (err.message.includes("429")) {
           res.status(429).json({ message: "AI usage limit reached. Please try again later." });
           return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (err instanceof Error) {
if (err.message.includes("429")) {
res.status(429).json({ message: "AI usage limit reached. Please try again later." });
return;
}
if (err.message.includes("Could not extract")) {
res.status(400).json({ message: err.message });
return;
}
if (err.message.includes("ENOENT")) {
res.status(404).json({ message: "Resume file not found. Please upload again." });
return;
}
}
res.status(500).json({ message: "Failed to improve resume. Please try again." });
}
if (err instanceof Error) {
if (err.message.includes("Resume does not belong")) {
res.status(403).json({ message: err.message });
return;
}
if (err.message.includes("User not found")) {
res.status(401).json({ message: err.message });
return;
}
if (err.message.includes("Invalid resume URL format")) {
res.status(400).json({ message: err.message });
return;
}
if (err.message.includes("429")) {
res.status(429).json({ message: "AI usage limit reached. Please try again later." });
return;
}
if (err.message.includes("Could not extract")) {
res.status(400).json({ message: err.message });
return;
}
if (err.message.includes("ENOENT")) {
res.status(404).json({ message: "Resume file not found. Please upload again." });
return;
}
}
res.status(500).json({ message: "Failed to improve resume. Please try again." });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/module/ats/ats.controller.ts` around lines 93 - 109, The catch
block in ats.controller.ts currently only maps "429", "Could not extract", and
"ENOENT" to 4xx; extend that block to map other service-domain/user errors to
4xx by inspecting err.message and err.name and returning the appropriate status
and message via res (e.g., if err.message or err.name includes
"ValidationError", "Invalid", "BadRequest" => res.status(400).json({ message:
err.message }); if it includes "Unauthorized" or "401" => res.status(401)...; if
it includes "Forbidden", "Ownership", or "403" => res.status(403)...; if it
includes "NotFound" or "404" => res.status(404)...). Keep existing checks for
"429", "Could not extract", and "ENOENT", and ensure all 4xx responses include
the original err.message for client diagnostics; fall back to res.status(500)
only for unexpected errors.

Comment on lines +102 to +104
const isOwned =
user.resumes.includes(input.resumeUrl) ||
getS3KeyFromUrl(input.resumeUrl) !== null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Tighten resume ownership check to prevent cross-user resume access.

This check currently authorizes any parseable S3 URL, not just URLs owned by the current student.

🔒 Suggested ownership-safe check
-    const isOwned =
-      user.resumes.includes(input.resumeUrl) ||
-      getS3KeyFromUrl(input.resumeUrl) !== null;
+    const requestedKey = getS3KeyFromUrl(input.resumeUrl);
+    const ownedS3Keys = new Set(
+      user.resumes
+        .map((resumeUrl) => getS3KeyFromUrl(resumeUrl))
+        .filter((key): key is string => key !== null),
+    );
+    const isOwned =
+      user.resumes.includes(input.resumeUrl) ||
+      (requestedKey !== null && ownedS3Keys.has(requestedKey));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isOwned =
user.resumes.includes(input.resumeUrl) ||
getS3KeyFromUrl(input.resumeUrl) !== null;
const requestedKey = getS3KeyFromUrl(input.resumeUrl);
const ownedS3Keys = new Set(
user.resumes
.map((resumeUrl) => getS3KeyFromUrl(resumeUrl))
.filter((key): key is string => key !== null),
);
const isOwned =
user.resumes.includes(input.resumeUrl) ||
(requestedKey !== null && ownedS3Keys.has(requestedKey));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/module/ats/ats.service.ts` around lines 102 - 104, The current
isOwned check wrongly treats any parseable S3 URL as owned; instead parse the S3
key with getS3KeyFromUrl(input.resumeUrl) and verify that that exact key (or the
canonical resume URL) is associated with the current user (e.g., compare against
user.resumes or user.resumeKeys), only granting ownership if
user.resumes.includes(parsedKey) or an equivalent user-owned identifier matches;
update the isOwned logic to use the parsed key and explicit membership check
rather than just nullity.

Comment on lines +15 to +17
export const applySuggestionsSchema = scoreResumeSchema.extend({
suggestions: z.array(z.string()).min(1, "At least one suggestion is required"),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound suggestions input to avoid prompt-size abuse.

suggestions is currently unbounded (count and item length). A crafted payload can create very large prompts and spike AI latency/cost.

🔧 Suggested schema hardening
 export const applySuggestionsSchema = scoreResumeSchema.extend({
-  suggestions: z.array(z.string()).min(1, "At least one suggestion is required"),
+  suggestions: z
+    .array(
+      z
+        .string()
+        .trim()
+        .min(1, "Suggestion cannot be empty")
+        .max(300, "Suggestion is too long"),
+    )
+    .min(1, "At least one suggestion is required")
+    .max(10, "Too many suggestions selected"),
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/module/ats/ats.validation.ts` around lines 15 - 17, The
applySuggestionsSchema currently allows an unbounded suggestions array which can
be abused; update the schema (applySuggestionsSchema which extends
scoreResumeSchema and defines suggestions) to enforce a reasonable maximum
number of items (e.g., .max(...)) and a maximum string length per suggestion
(use z.string().max(...)) and keep the existing .min(1). Also consider
trimming/normalizing inputs (e.g., .trim() or a transform) to avoid whitespace
abuse and include clear error messages for both the array and item limits.

@Sachinchaurasiya360 Sachinchaurasiya360 added enhancement New feature or request good first issue Good for newcomers level:beginner Good for first-time contributors level:intermediate Requires moderate project understanding gssoc:approved Approved for GSSoC scoring labels May 21, 2026
@Sachinchaurasiya360
Copy link
Copy Markdown
Owner

Can you check for the conflict

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request good first issue Good for newcomers gssoc:approved Approved for GSSoC scoring level:beginner Good for first-time contributors level:intermediate Requires moderate project understanding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestions are passive — no "Apply" action to generate an improved resume

2 participants