Skip to content
Merged
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ MODEL_NAME="gemini-2.5-flash"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
RESUME_ROUTE_TIMEOUT_MS="45000"
LATEX_RENDER_API_BASE="https://latexonline.cc"

# Convex Variables
NEXT_PUBLIC_CONVEX_URL=""
Expand Down
15 changes: 3 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ AI-powered resume analysis and cover-letter generation built with Next.js, Stack

- Resume analysis with match scoring, strengths, weaknesses, skills match, and recommendations
- Cover-letter generation with configurable tone and length
- Tailored LaTeX resume generation using selectable templates
- PDF resume upload and parsing
- Searchable history for analyses and cover letters
- Auth-protected dashboard flows
Expand Down Expand Up @@ -68,6 +69,8 @@ MODEL_NAME="gemini-2.5-flash"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
RESUME_ROUTE_TIMEOUT_MS="45000"
LATEX_RENDER_API_BASE="https://latexonline.cc"
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
# Only set true for local emergency fallback; keep false/empty in production
Expand All @@ -82,15 +85,3 @@ bun run dev
```

App runs at `http://localhost:3000`.

## Scripts

```bash
bun run dev # Start Next.js dev server
bun run build # Build app
bun run start # Start production server
bun run lint # Run ESLint
bun run test # Run tests once (Vitest)
bun run test:watch # Run tests in watch mode
bun run setup-db # One-time Convex setup (convex dev --once)
```
125 changes: 125 additions & 0 deletions convex/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,108 @@ export const getUserCoverLetters = query({
},
});

// ─── Tailored Resume Functions ───────────────────────────────────────

export const saveTailoredResume = mutation({
args: {
userId: v.string(),
resumeHash: v.string(),
jobDescriptionHash: v.string(),
templateId: v.string(),
jobTitle: v.optional(v.string()),
companyName: v.optional(v.string()),
resumeName: v.optional(v.string()),
jobDescription: v.optional(v.string()),
structuredData: v.string(),
latexSource: v.string(),
builderSlug: v.optional(v.string()),
version: v.optional(v.number()),
sourceAnalysisId: v.optional(v.string()),
customTemplateName: v.optional(v.string()),
customTemplateSource: v.optional(v.string()),
},
handler: async (ctx, args) => {
const id = await ctx.db.insert("tailoredResumes", args);
const doc = await ctx.db.get(id);
return doc;
},
});

export const getTailoredResume = query({
args: {
userId: v.string(),
resumeHash: v.string(),
jobDescriptionHash: v.string(),
templateId: v.string(),
},
handler: async (ctx, args) => {
const doc = await ctx.db
.query("tailoredResumes")
.filter((q) =>
q.and(
q.eq(q.field("userId"), args.userId),
q.eq(q.field("resumeHash"), args.resumeHash),
q.eq(q.field("jobDescriptionHash"), args.jobDescriptionHash),
q.eq(q.field("templateId"), args.templateId),
)
)
.order("desc")
.first();
return doc;
},
});
Comment on lines +275 to +297
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find schema files
fd -e ts -e js | grep -i schema | head -20

Repository: Aditya190803/Application-Tracking-System

Length of output: 100


🏁 Script executed:

# Search for tailoredResumes table definition
rg -n "tailoredResumes" --type=ts -B2 -A10 | head -100

Repository: Aditya190803/Application-Tracking-System

Length of output: 5460


🏁 Script executed:

# Look for by_lookup index definition
rg -n "by_lookup" --type=ts -C5

Repository: Aditya190803/Application-Tracking-System

Length of output: 1887


getTailoredResume performs a full table scan — use the by_lookup index instead.

The schema defines a by_lookup index on [userId, resumeHash, jobDescriptionHash, templateId], but this query uses .filter() which scans the entire table. For any non-trivial data volume this will degrade performance.

⚡ Proposed fix
     handler: async (ctx, args) => {
         const doc = await ctx.db
             .query("tailoredResumes")
-            .filter((q) =>
-                q.and(
-                    q.eq(q.field("userId"), args.userId),
-                    q.eq(q.field("resumeHash"), args.resumeHash),
-                    q.eq(q.field("jobDescriptionHash"), args.jobDescriptionHash),
-                    q.eq(q.field("templateId"), args.templateId),
-                )
-            )
+            .withIndex("by_lookup", (q) =>
+                q.eq("userId", args.userId)
+                 .eq("resumeHash", args.resumeHash)
+                 .eq("jobDescriptionHash", args.jobDescriptionHash)
+                 .eq("templateId", args.templateId)
+            )
             .order("desc")
             .first();
         return doc;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/functions.ts` around lines 275 - 297, The query in getTailoredResume
on tailoredResumes does a full table scan via .filter(); change it to use the
by_lookup indexed lookup by calling the Convex index-based API (e.g.,
ctx.db.query("tailoredResumes").withIndex("by_lookup").get([...]) or equivalent)
passing the tuple [args.userId, args.resumeHash, args.jobDescriptionHash,
args.templateId] in the same order as the index, and return the single result
(or .first() if using a query wrapper) so the lookup uses the by_lookup index
instead of scanning the table.


export const getTailoredResumeById = query({
args: { tailoredResumeId: v.id("tailoredResumes") },
handler: async (ctx, args) => {
return await ctx.db.get(args.tailoredResumeId);
},
});

export const deleteTailoredResume = mutation({
args: { tailoredResumeId: v.id("tailoredResumes") },
handler: async (ctx, args) => {
await ctx.db.delete(args.tailoredResumeId);
return { success: true };
},
});

export const getUserTailoredResumes = query({
args: {
userId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? 20;
const docs = await ctx.db
.query("tailoredResumes")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.order("desc")
.take(limit);
return docs;
},
});

export const getTailoredResumeVersionsBySlug = query({
args: {
userId: v.string(),
builderSlug: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? 30;
const docs = await ctx.db
.query("tailoredResumes")
.withIndex("by_userId_builderSlug", (q) =>
q.eq("userId", args.userId)
)
.filter((q) => q.eq(q.field("builderSlug"), args.builderSlug))
.order("desc")
.take(limit);
return docs;
},
});
Comment on lines +330 to +348
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the by_userId_builderSlug index definition
rg -n 'by_userId_builderSlug' --type=ts -C 5

Repository: Aditya190803/Application-Tracking-System

Length of output: 1201


🏁 Script executed:

#!/bin/bash
# Look for index definitions in the schema/database setup
fd . -type f -name "*.ts" | head -20
git ls-files | grep -E "(schema|index|table)" | head -20

Repository: Aditya190803/Application-Tracking-System

Length of output: 353


🏁 Script executed:

#!/bin/bash
# Find other withIndex usages to see the pattern
rg -n 'withIndex' --type=ts -A 3 | head -50

Repository: Aditya190803/Application-Tracking-System

Length of output: 2518


🏁 Script executed:

#!/bin/bash
# Search for examples of multiple .eq() chaining in the codebase
rg -n '\.eq\(' --type=ts -B 1 -A 1 | grep -A 2 "\.eq" | head -40

Repository: Aditya190803/Application-Tracking-System

Length of output: 2121


🏁 Script executed:

#!/bin/bash
# Look for the complete context around lines 126-130 to understand the .and() pattern
sed -n '124,131p' convex/functions.ts

Repository: Aditya190803/Application-Tracking-System

Length of output: 441


🏁 Script executed:

#!/bin/bash
# Search for .and() usage with compound index or withIndex
rg -n '\.and\(' convex/functions.ts -B 2 -A 2 | head -60

Repository: Aditya190803/Application-Tracking-System

Length of output: 775


🏁 Script executed:

#!/bin/bash
# Search for any examples of withIndex with multiple conditions
rg -n 'withIndex.*\(.*q\s*=>' convex/functions.ts -A 5 | head -80

Repository: Aditya190803/Application-Tracking-System

Length of output: 66


🏁 Script executed:

#!/bin/bash
# Check if there's any documentation or test files showing Convex API usage
fd . -type f \( -name "*.md" -o -name "*.json" \) | xargs grep -l "withIndex" 2>/dev/null

Repository: Aditya190803/Application-Tracking-System

Length of output: 256


🏁 Script executed:

#!/bin/bash
# Search for documentation in package.json or convex config
cat convex.json 2>/dev/null || echo "No convex.json"
cat package.json | grep -A 5 "convex" || echo "No convex in package.json"

Repository: Aditya190803/Application-Tracking-System

Length of output: 451


🏁 Script executed:

#!/bin/bash
# Look at the complete function to understand the context better
sed -n '330,350p' convex/functions.ts

Repository: Aditya190803/Application-Tracking-System

Length of output: 752


🏁 Script executed:

#!/bin/bash
# Check if there are any README files or documentation
find . -name "README*" -o -name "*.md" | head -10

Repository: Aditya190803/Application-Tracking-System

Length of output: 731


🏁 Script executed:

#!/bin/bash
# Search online for Convex compound index documentation pattern
# Let's also check the actual Convex version to understand capabilities
cat package.json | grep '"convex"'

Repository: Aditya190803/Application-Tracking-System

Length of output: 108


🌐 Web query:

Convex withIndex compound index multiple eq chaining API

💡 Result:

In Convex, compound (multi-field) indexes are queried by chaining comparisons inside withIndex, and you can chain multiple .eq() calls as long as you do it in the same order as the index fields.

1) Define a compound index

defineTable({
  teamId: v.id("teams"),
  status: v.string(),
}).index("by_teamId_status", ["teamId", "status"]);

(Example pattern shown in Convex guidance.) [1]

2) Query it with multiple .eq() (chaining)

const rows = await ctx.db
  .query("members")
  .withIndex("by_teamId_status", (q) =>
    q.eq("teamId", args.teamId).eq("status", "active")
  )
  .collect();

This is the intended “multiple eq chaining” API for compound indexes. [1][2]

3) Rules: what chaining is allowed in an index range expression

An index range expression is a chained list of: (a) zero or more .eq(), then (b) optional .gt/.gte, then (c) optional .lt/.lte, and you must step through fields in index order. [2][3]

So if your index is ["a","b","c"], these are valid:

  • q.eq("a", 1)
  • q.eq("a", 1).eq("b", 2)
  • q.eq("a", 1).eq("b", 2).gte("c", 10).lt("c", 20)

And these are not valid:

  • q.eq("b", 2) (skips a)
  • q.eq("a", 1).eq("c", 3) (skips b)
  • q.gt("a", 1).eq("b", 2) (.eq must come before range bounds) [2]

4) If you need extra predicates not supported by the index range

Use .filter(...) after .withIndex(...), but note performance is then proportional to how many docs are in the indexed range you selected. [2][3]

Sources: Convex docs on indexes and withIndex/index range expressions, plus examples of multi-field indexes and chained .eq. [1][2][3]


🌐 Web query:

Convex database query withIndex documentation compound index

💡 Result:

To do a compound (multi-field) index query in Convex, you:

  1. Define a compound index in your schema (field order matters):
// convex/schema.ts
defineTable({
  author: v.string(),
  title: v.string(),
  text: v.string(),
}).index("by_author_title", ["author", "title"]);

[1]

  1. Query it with withIndex, matching fields from left to right in the index definition (typically eq on a prefix, then optional range on the next field):
const book = await ctx.db
  .query("books")
  .withIndex("by_author_title", (q) =>
    q.eq("author", "Isaac Asimov").eq("title", "Foundation")
  )
  .unique();

[2]

  1. Range query on the next field after the equality prefix (still in index order):
const books = await ctx.db
  .query("books")
  .withIndex("by_author_title", (q) =>
    q.eq("author", "Isaac Asimov").gte("title", "F").lt("title", "G")
  )
  .collect();

[2]

Key rule from the docs: your withIndex “range expression” is a chain of 0+ eq, then optionally gt/gte, then optionally lt/lte, and you must step through fields in index order (you can’t skip the leading field and range on a later one). [1]


getTailoredResumeVersionsBySlug under-utilizes the compound index.

The by_userId_builderSlug index covers both userId and builderSlug, but only userId is matched via .withIndex() while builderSlug falls back to .filter(). Chain both .eq() calls in the index range expression to leverage the full compound index and avoid scanning all of the user's resumes.

⚡ Proposed fix
         const docs = await ctx.db
             .query("tailoredResumes")
             .withIndex("by_userId_builderSlug", (q) =>
-                q.eq("userId", args.userId)
+                q.eq("userId", args.userId).eq("builderSlug", args.builderSlug)
             )
-            .filter((q) => q.eq(q.field("builderSlug"), args.builderSlug))
             .order("desc")
             .take(limit);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/functions.ts` around lines 330 - 348, getTailoredResumeVersionsBySlug
is not using the full compound index by_userId_builderSlug because builderSlug
is applied in a .filter() instead of the index range; update the .withIndex call
to chain both equality predicates (e.g., in the withIndex callback call
q.eq("userId", args.userId).eq("builderSlug", args.builderSlug)) and remove the
separate .filter(...) so the query leverages the compound index and then keep
the .order("desc").take(limit) and return docs as before.


// ─── Stats ───────────────────────────────────────────────────────────

export const getUserStats = query({
Expand Down Expand Up @@ -320,6 +422,12 @@ export const getSearchHistory = query({
.order("desc")
.take(limit * 2);

const tailoredResumesRaw = await ctx.db
.query("tailoredResumes")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.order("desc")
.take(limit * 2);

const analyses = cursorTime
? analysesRaw.filter((doc) => doc._creationTime < cursorTime)
: analysesRaw;
Expand All @@ -328,6 +436,10 @@ export const getSearchHistory = query({
? coverLettersRaw.filter((doc) => doc._creationTime < cursorTime)
: coverLettersRaw;

const tailoredResumes = cursorTime
? tailoredResumesRaw.filter((doc) => doc._creationTime < cursorTime)
: tailoredResumesRaw;

const history = [
...analyses.map((doc) => ({
id: doc._id,
Expand All @@ -349,6 +461,19 @@ export const getSearchHistory = query({
createdAt: new Date(doc._creationTime).toISOString(),
result: doc.result,
})),
...tailoredResumes.map((doc) => ({
id: doc._id,
type: "resume" as const,
companyName: doc.companyName,
resumeName: doc.resumeName,
jobTitle: doc.jobTitle,
jobDescription: doc.jobDescription,
templateId: doc.templateId,
builderSlug: doc.builderSlug,
version: doc.version,
createdAt: new Date(doc._creationTime).toISOString(),
result: doc.latexSource,
})),
];

history.sort(
Expand Down
21 changes: 21 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,25 @@ export default defineSchema({
})
.index("by_userId", ["userId"])
.index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "tone", "length"]),

tailoredResumes: defineTable({
userId: v.string(),
resumeHash: v.string(),
jobDescriptionHash: v.string(),
templateId: v.string(),
jobTitle: v.optional(v.string()),
companyName: v.optional(v.string()),
resumeName: v.optional(v.string()),
jobDescription: v.optional(v.string()),
structuredData: v.string(),
latexSource: v.string(),
builderSlug: v.optional(v.string()),
version: v.optional(v.number()),
sourceAnalysisId: v.optional(v.string()),
customTemplateName: v.optional(v.string()),
customTemplateSource: v.optional(v.string()),
})
.index("by_userId", ["userId"])
.index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "templateId"])
.index("by_userId_builderSlug", ["userId", "builderSlug"]),
});
Loading