Skip to content

feat: add AI cover letter generator - #36

Merged
mankatcheung merged 1 commit into
mainfrom
feat/cover-letter
Jul 21, 2026
Merged

mankatcheung merged 1 commit into
mainfrom
feat/cover-letter

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adds GenerateCoverLetterUseCase — validates ownership, builds a personalised prompt from the application's company/role/description, calls the ILLMProvider port (OpenRouter by default), and returns the generated text
  • New generateCoverLetter(applicationId: ID!, resumeText: String): String! GraphQL mutation
  • Web: "Cover Letter" tab on the application detail page — optional resume text paste area, loading spinner, generated letter display with copy-to-clipboard
  • HTMX: "Cover Letter" tab with HTMX form posting to POST /applications/:id/cover-letter; server returns a pre-rendered HTML fragment with a JS copy button

Test plan

  • 5 unit tests for GenerateCoverLetterUseCase (all passing, 225 total)
  • pnpm --filter @job-finder/api typecheck passes
  • pnpm --filter @job-finder/web typecheck passes
  • Open an application in the web app → "Cover Letter" tab → paste optional resume → click Generate → letter appears with Copy button
  • Same flow in the HTMX app
  • Application not found → error displayed (not crash)
  • Unauthorized application → error displayed

Summary by CodeRabbit

  • New Features
    • Added a Cover Letter tab to application details.
    • Generate tailored cover letters using application information and optional CV or background text.
    • View generated letters and copy them to the clipboard.
    • Available in both the web and HTMX interfaces.
  • Bug Fixes
    • Added clear handling for unauthorised access, missing applications and generation failures.
  • Tests
    • Added coverage for successful generation, application context, optional CV text and error scenarios.

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
@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds 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.

Changes

Cover Letter Generation

Layer / File(s) Summary
Generation use case and validation
apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts, apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts
The use case loads and authorises applications, builds truncated prompts, invokes the LLM provider, and has tests for successful output and error cases.
GraphQL exposure and dependency wiring
apps/api/src/http/container.ts, apps/api/src/http/schema/index.ts, apps/api/src/http/schema/mutations/coverLetterMutations.ts
The use case is registered for injection and exposed through an authenticated generateCoverLetter mutation.
Application detail interfaces
apps/htmx/src/routes/applications/detail.ts, apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx
Both application-detail interfaces add a cover-letter tab, generation controls, result rendering, error states, loading states, and copy actions.

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
Loading

Possibly related PRs

Poem

A bunny drafts beneath the moon,
With prompts that make the words bloom.
Tabs now open, letters appear,
Copy one softly, crisp and clear.
Hop, hop—the cover note is here!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarises the main change: adding an AI cover letter generator.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cover-letter

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

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.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between da86eff and ab565db.

📒 Files selected for processing (7)
  • apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
  • apps/api/src/http/schema/mutations/coverLetterMutations.ts
  • apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts
  • apps/htmx/src/routes/applications/detail.ts
  • apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx

Comment on lines +21 to +23
} catch (err) {
throw new GraphQLError((err as Error).message ?? 'Failed to generate cover letter');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 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-L307
  • apps/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.

Comment on lines +27 to +33
return this.deps.llmProvider.complete(
[
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
1024,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i OpenRouterLLMProvider
rg -n -A15 'class OpenRouterLLMProvider' --type=ts

Repository: 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 -ba

Repository: 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('---')
PY

Repository: 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.

Comment on lines +287 to +298
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>`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Suggested change
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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant