feat: add AI cover letter generator - #36
Conversation
Adds a cover letter generator powered by the ILLMProvider port. Given a job application (and optional pasted resume text), the LLM produces a concise 3-4 paragraph cover letter personalised to the company, role, and job description. - `GenerateCoverLetterUseCase` — validates ownership, builds prompt, calls LLM; 5 unit tests covering happy path, resume inclusion, context injection, and authorization errors - `coverLetterMutations.ts` — `generateCoverLetter(applicationId, resumeText): String!` - Web: "Cover Letter" tab on the application detail page with optional resume textarea, loading state, and copy-to-clipboard - HTMX: "Cover Letter" tab with HTMX form posting to `POST /applications/:id/cover-letter`; returns pre-rendered letter fragment with a JS copy button
WalkthroughAdds cover-letter generation using application context and optional resume text, exposes it through GraphQL, and integrates it into both HTMX and web application-detail interfaces with loading, error, display, and copy behaviour. ChangesCover Letter Generation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Applicant
participant ApplicationDetail
participant GraphQL
participant GenerateCoverLetterUseCase
participant ApplicationRepository
participant LLMProvider
Applicant->>ApplicationDetail: Submit resume or background text
ApplicationDetail->>GraphQL: Request generateCoverLetter
GraphQL->>GenerateCoverLetterUseCase: Execute authenticated request
GenerateCoverLetterUseCase->>ApplicationRepository: Find application
GenerateCoverLetterUseCase->>LLMProvider: Complete cover-letter prompt
LLMProvider-->>GenerateCoverLetterUseCase: Return generated text
GenerateCoverLetterUseCase-->>GraphQL: Return cover letter
GraphQL-->>ApplicationDetail: Return generated text
ApplicationDetail-->>Applicant: Display and copy cover letter
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install timed out. The project may have too many dependencies for the sandbox. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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 `@apps/api/src/http/schema/mutations/coverLetterMutations.ts`:
- Around line 21-23: Sanitize errors in the cover-letter GraphQL resolver catch
block by allowing only “Application not found” and “Unauthorized”; log all other
errors server-side and throw a generic fallback message. No direct changes are
needed at apps/htmx/src/routes/applications/detail.ts:299-307 or
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx:1454-1458,
as both are corrected by the resolver change.
In `@apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts`:
- Around line 27-33: Add a timeout to the OpenRouter request initiated through
GenerateCoverLetterUseCase and OpenRouterLLMProvider.complete by creating an
AbortController, scheduling its abort after the configured timeout, and passing
the resulting signal to fetch. Ensure the timer is cleared when the request
completes or fails so the GraphQL resolver cannot remain blocked indefinitely.
In `@apps/htmx/src/routes/applications/detail.ts`:
- Around line 287-298: Fix the Copy button handler in the generated cover-letter
markup so it selects the outer container that contains both the button and the
pre element, rather than the immediate header div. Update the lookup used by the
onclick handler and preserve the existing clipboard write and “✓ Copied”
feedback behavior.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2ef80ffb-8753-4e00-b22a-374cba1d0a26
📒 Files selected for processing (7)
apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.tsapps/api/src/http/container.tsapps/api/src/http/schema/index.tsapps/api/src/http/schema/mutations/coverLetterMutations.tsapps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.tsapps/htmx/src/routes/applications/detail.tsapps/web/src/routes/_authenticated/applications/$applicationId/index.tsx
| } catch (err) { | ||
| throw new GraphQLError((err as Error).message ?? 'Failed to generate cover letter'); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Unfiltered upstream errors are surfaced to end users in both UIs. The root cause is the GraphQL resolver's catch-all, which re-throws any caught error's raw .message (not just the known use-case errors); both the HTMX and web UIs then render that message verbatim.
apps/api/src/http/schema/mutations/coverLetterMutations.ts#L21-L23: allow-list the known safe messages ('Application not found','Unauthorized'), log anything else server-side, and throw a generic fallback message for unrecognised errors.apps/htmx/src/routes/applications/detail.ts#L299-L307: no separate change needed once the resolver only returns safe messages — this site will automatically stop leaking provider internals.apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx#L1454-L1458: same as above — resolved once the resolver's error output is sanitised.
📍 Affects 3 files
apps/api/src/http/schema/mutations/coverLetterMutations.ts#L21-L23(this comment)apps/htmx/src/routes/applications/detail.ts#L299-L307apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx#L1454-L1458
🤖 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 `@apps/api/src/http/schema/mutations/coverLetterMutations.ts` around lines 21 -
23, Sanitize errors in the cover-letter GraphQL resolver catch block by allowing
only “Application not found” and “Unauthorized”; log all other errors
server-side and throw a generic fallback message. No direct changes are needed
at apps/htmx/src/routes/applications/detail.ts:299-307 or
apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx:1454-1458,
as both are corrected by the resolver change.
| return this.deps.llmProvider.complete( | ||
| [ | ||
| { role: 'system', content: SYSTEM_PROMPT }, | ||
| { role: 'user', content: userPrompt }, | ||
| ], | ||
| 1024, | ||
| ); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -i OpenRouterLLMProvider
rg -n -A15 'class OpenRouterLLMProvider' --type=tsRepository: mankatcheung/job-finder
Length of output: 218
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts --view expanded || true
printf '\n--- file ---\n'
wc -l apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts
cat -n apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts | sed -n '1,260p'
printf '\n--- container refs ---\n'
rg -n "OpenRouterLLMProvider|ILLMProvider|timeout|AbortSignal|AbortController|fetch\\(" apps/api/src -g '*.ts'Repository: mankatcheung/job-finder
Length of output: 4464
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,240p' apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts | nl -ba
printf '\n--- container ---\n'
sed -n '1,260p' apps/api/src/container.ts | nl -baRepository: mankatcheung/job-finder
Length of output: 201
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path in [
Path('apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts'),
Path('apps/api/src/container.ts'),
]:
print(f"\n### {path} ###")
text = path.read_text()
lines = text.splitlines()
for i, line in enumerate(lines, 1):
if 'timeout' in line.lower() or 'abort' in line.lower() or 'fetch(' in line or 'OpenRouterLLMProvider' in line or 'llmProvider' in line:
start = max(1, i-8)
end = min(len(lines), i+20)
for j in range(start, end+1):
print(f"{j:4}: {lines[j-1]}")
print('---')
PYRepository: mankatcheung/job-finder
Length of output: 2895
Add a timeout around the OpenRouter call. OpenRouterLLMProvider.complete() uses fetch() without an AbortSignal, so a slow or hung upstream request can block the GraphQL resolver indefinitely.
🤖 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 `@apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts` around
lines 27 - 33, Add a timeout to the OpenRouter request initiated through
GenerateCoverLetterUseCase and OpenRouterLLMProvider.complete by creating an
AbortController, scheduling its abort after the configured timeout, and passing
the resulting signal to fetch. Ensure the timer is cleared when the request
completes or fails so the GraphQL resolver cannot remain blocked indefinitely.
| return reply.type('text/html').send(` | ||
| <div class="bg-white rounded-xl border border-gray-200 p-4 space-y-3"> | ||
| <div class="flex items-center justify-between"> | ||
| <h3 class="text-sm font-semibold text-gray-700">Generated cover letter</h3> | ||
| <button | ||
| onclick="navigator.clipboard.writeText(this.closest('div').querySelector('pre').innerText); this.textContent='✓ Copied'; setTimeout(()=>this.textContent='Copy',2000)" | ||
| class="px-3 py-1.5 text-xs font-medium border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"> | ||
| Copy | ||
| </button> | ||
| </div> | ||
| <pre class="text-sm text-gray-800 whitespace-pre-wrap font-sans leading-relaxed">${text}</pre> | ||
| </div>`); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Copy button is broken: closest('div') resolves to the wrong ancestor.
this.closest('div') starts matching at the element itself, then walks up — the button's immediate parent (<div class="flex items-center justify-between">) already matches div and is returned first. That div doesn't contain the <pre>, which is a sibling at the outer-div level, so querySelector('pre') returns null and .innerText throws a TypeError, aborting the handler before the "✓ Copied" feedback ever runs. The Copy button currently does nothing.
🐛 Suggested fix: target the outer container explicitly
- <div class="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
+ <div id="cover-letter-output" class="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700">Generated cover letter</h3>
<button
- onclick="navigator.clipboard.writeText(this.closest('div').querySelector('pre').innerText); this.textContent='✓ Copied'; setTimeout(()=>this.textContent='Copy',2000)"
+ onclick="navigator.clipboard.writeText(document.getElementById('cover-letter-output').querySelector('pre').innerText); this.textContent='✓ Copied'; setTimeout(()=>this.textContent='Copy',2000)"
class="px-3 py-1.5 text-xs font-medium border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">
Copy
</button>
</div>
<pre class="text-sm text-gray-800 whitespace-pre-wrap font-sans leading-relaxed">${text}</pre>
</div>`);📝 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.
| return reply.type('text/html').send(` | |
| <div class="bg-white rounded-xl border border-gray-200 p-4 space-y-3"> | |
| <div class="flex items-center justify-between"> | |
| <h3 class="text-sm font-semibold text-gray-700">Generated cover letter</h3> | |
| <button | |
| onclick="navigator.clipboard.writeText(this.closest('div').querySelector('pre').innerText); this.textContent='✓ Copied'; setTimeout(()=>this.textContent='Copy',2000)" | |
| class="px-3 py-1.5 text-xs font-medium border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"> | |
| Copy | |
| </button> | |
| </div> | |
| <pre class="text-sm text-gray-800 whitespace-pre-wrap font-sans leading-relaxed">${text}</pre> | |
| </div>`); | |
| return reply.type('text/html').send(` | |
| <div id="cover-letter-output" class="bg-white rounded-xl border border-gray-200 p-4 space-y-3"> | |
| <div class="flex items-center justify-between"> | |
| <h3 class="text-sm font-semibold text-gray-700">Generated cover letter</h3> | |
| <button | |
| onclick="navigator.clipboard.writeText(document.getElementById('cover-letter-output').querySelector('pre').innerText); this.textContent='✓ Copied'; setTimeout(()=>this.textContent='Copy',2000)" | |
| class="px-3 py-1.5 text-xs font-medium border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"> | |
| Copy | |
| </button> | |
| </div> | |
| <pre class="text-sm text-gray-800 whitespace-pre-wrap font-sans leading-relaxed">${text}</pre> | |
| </div>`); |
🤖 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 `@apps/htmx/src/routes/applications/detail.ts` around lines 287 - 298, Fix the
Copy button handler in the generated cover-letter markup so it selects the outer
container that contains both the button and the pre element, rather than the
immediate header div. Update the lookup used by the onclick handler and preserve
the existing clipboard write and “✓ Copied” feedback behavior.
Summary
GenerateCoverLetterUseCase— validates ownership, builds a personalised prompt from the application's company/role/description, calls theILLMProviderport (OpenRouter by default), and returns the generated textgenerateCoverLetter(applicationId: ID!, resumeText: String): String!GraphQL mutationPOST /applications/:id/cover-letter; server returns a pre-rendered HTML fragment with a JS copy buttonTest plan
GenerateCoverLetterUseCase(all passing, 225 total)pnpm --filter @job-finder/api typecheckpassespnpm --filter @job-finder/web typecheckpassesSummary by CodeRabbit