Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest';
import { GenerateCoverLetterUseCase } from '@/use-cases/coverLetter/GenerateCoverLetterUseCase.js';
import { makeApplicationRepository, makeApplication } from '@/__tests__/helpers/mocks.js';
import type { ILLMProvider } from '@/use-cases/ports/ILLMProvider.js';

const COVER_LETTER = 'Dear Hiring Manager,\n\nI am excited to apply…\n\nSincerely,\nJane';

function makeLLMProvider(response = COVER_LETTER): ILLMProvider {
return { complete: vi.fn().mockResolvedValue(response) };
}

describe('GenerateCoverLetterUseCase', () => {
it('returns the LLM response as the cover letter', async () => {
const app = makeApplication({ description: 'We build great software.' });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(app),
});
const llmProvider = makeLLMProvider();

const result = await new GenerateCoverLetterUseCase({
applicationRepository,
llmProvider,
}).execute({
applicationId: 'app-1',
userId: 'user-1',
});

expect(result).toBe(COVER_LETTER);
expect(llmProvider.complete).toHaveBeenCalledOnce();
});

it('includes resume text in the prompt when provided', async () => {
const app = makeApplication();
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(app),
});
const llmProvider = makeLLMProvider();

await new GenerateCoverLetterUseCase({ applicationRepository, llmProvider }).execute({
applicationId: 'app-1',
userId: 'user-1',
resumeText: '5 years at BigCorp building APIs',
});

const [messages] = (llmProvider.complete as ReturnType<typeof vi.fn>).mock.calls[0] as [
Array<{ role: string; content: string }>,
];
const userMessage = messages.find((m) => m.role === 'user')!;
expect(userMessage.content).toContain('5 years at BigCorp building APIs');
});

it('includes application context in the prompt', async () => {
const app = makeApplication({
company: 'Stripe',
role: 'Staff Engineer',
description: 'Payments infra role',
});
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(app),
});
const llmProvider = makeLLMProvider();

await new GenerateCoverLetterUseCase({ applicationRepository, llmProvider }).execute({
applicationId: 'app-1',
userId: 'user-1',
});

const [messages] = (llmProvider.complete as ReturnType<typeof vi.fn>).mock.calls[0] as [
Array<{ role: string; content: string }>,
];
const userMessage = messages.find((m) => m.role === 'user')!;
expect(userMessage.content).toContain('Stripe');
expect(userMessage.content).toContain('Staff Engineer');
expect(userMessage.content).toContain('Payments infra role');
});

it('throws when application is not found', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(null),
});
const llmProvider = makeLLMProvider();

await expect(
new GenerateCoverLetterUseCase({ applicationRepository, llmProvider }).execute({
applicationId: 'missing',
userId: 'user-1',
}),
).rejects.toThrow('Application not found');
});

it('throws when application belongs to a different user', async () => {
const app = makeApplication({ userId: 'user-2' });
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(app),
});
const llmProvider = makeLLMProvider();

await expect(
new GenerateCoverLetterUseCase({ applicationRepository, llmProvider }).execute({
applicationId: 'app-1',
userId: 'user-1',
}),
).rejects.toThrow('Unauthorized');
});
});
5 changes: 5 additions & 0 deletions apps/api/src/http/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { SendFollowUpRemindersUseCase } from '@/use-cases/reminders/SendFollowUp
import { PrismaTransactionManager } from '@/infrastructure/db/PrismaTransactionManager.js';
import { OpenRouterLLMProvider } from '@/infrastructure/llm/OpenRouterLLMProvider.js';
import { ParseJobDescriptionUseCase } from '@/use-cases/jobDescription/ParseJobDescriptionUseCase.js';
import { GenerateCoverLetterUseCase } from '@/use-cases/coverLetter/GenerateCoverLetterUseCase.js';

import type { FastifyInstance } from 'fastify';

Expand Down Expand Up @@ -159,6 +160,7 @@ declare module '@fastify/awilix' {
transactionManager: PrismaTransactionManager;
llmProvider: OpenRouterLLMProvider;
parseJobDescriptionUseCase: ParseJobDescriptionUseCase;
generateCoverLetterUseCase: GenerateCoverLetterUseCase;
}
}

Expand Down Expand Up @@ -273,5 +275,8 @@ export function buildContainer(fastify: FastifyInstance): void {
parseJobDescriptionUseCase: asClass(ParseJobDescriptionUseCase, {
lifetime: Lifetime.TRANSIENT,
}),
generateCoverLetterUseCase: asClass(GenerateCoverLetterUseCase, {
lifetime: Lifetime.TRANSIENT,
}),
});
}
1 change: 1 addition & 0 deletions apps/api/src/http/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ import './mutations/interviewRoundMutations.js';
import './mutations/apiTokenMutations.js';
import './mutations/contactMutations.js';
import './mutations/jobDescriptionMutations.js';
import './mutations/coverLetterMutations.js';

export const schema = builder.toSchema();
26 changes: 26 additions & 0 deletions apps/api/src/http/schema/mutations/coverLetterMutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { GraphQLError } from 'graphql';
import { builder } from '@/http/schema/builder.js';

builder.mutationField('generateCoverLetter', (t) =>
t.field({
type: 'String',
args: {
applicationId: t.arg.id({ required: true }),
resumeText: t.arg.string({ required: false }),
},
resolve: async (_root, args, ctx) => {
if (!ctx.user)
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHORIZED' } });
const { generateCoverLetterUseCase } = ctx.diScope.cradle;
try {
return await generateCoverLetterUseCase.execute({
applicationId: args.applicationId,
userId: ctx.user.sub,
resumeText: args.resumeText,
});
} catch (err) {
throw new GraphQLError((err as Error).message ?? 'Failed to generate cover letter');
}
Comment on lines +21 to +23

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.

},
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ILLMProvider } from '@/use-cases/ports/ILLMProvider.js';
import type { IApplicationRepository } from '@/use-cases/ports/IApplicationRepository.js';

export interface GenerateCoverLetterInput {
applicationId: string;
userId: string;
resumeText?: string | null;
}

interface Deps {
llmProvider: ILLMProvider;
applicationRepository: IApplicationRepository;
}

const SYSTEM_PROMPT = `You are a professional cover letter writer. Write compelling, personalized cover letters that are concise (3-4 paragraphs), specific to the role, and written in first person. Return ONLY the cover letter body — no subject line, no date, no address block, no explanation.`;

export class GenerateCoverLetterUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: GenerateCoverLetterInput): Promise<string> {
const app = await this.deps.applicationRepository.findById(input.applicationId);
if (!app) throw new Error('Application not found');
if (app.userId !== input.userId) throw new Error('Unauthorized');

const userPrompt = this.buildPrompt(app, input.resumeText);

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

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.

}

private buildPrompt(
app: { company: string; role: string; location?: string | null; description?: string | null },
resumeText?: string | null,
): string {
const lines = [
`Write a cover letter for this job application:`,
`Company: ${app.company}`,
`Role: ${app.role}`,
...(app.location ? [`Location: ${app.location}`] : []),
...(app.description ? [`\nJob description:\n${app.description.slice(0, 3000)}`] : []),
...(resumeText?.trim()
? [`\nMy background / resume:\n${resumeText.trim().slice(0, 4000)}`]
: ['\nWrite a strong general cover letter for someone applying to this role.']),
];
return lines.join('\n');
}
}
79 changes: 77 additions & 2 deletions apps/htmx/src/routes/applications/detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const GET_APP = `query GetApplication($id: ID!) {
}
}`;

const GENERATE_COVER_LETTER = `mutation GenerateCoverLetter($applicationId: ID!, $resumeText: String) {
generateCoverLetter(applicationId: $applicationId, resumeText: $resumeText)
}`;

const TOGGLE_STAR = `mutation ToggleStar($id: ID!, $starred: Boolean!) {
updateApplication(id: $id, input: { starred: $starred }) { id starred }
}`;
Expand Down Expand Up @@ -63,14 +67,15 @@ const linkIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
function detailPage(app: FullApp, activeTab = 'overview'): string {
const overdue = app.followUpAt != null && new Date(app.followUpAt) <= new Date();

const tabs = ['overview', 'notes', 'contacts', 'interviews'];
const tabs = ['overview', 'notes', 'contacts', 'interviews', 'cover-letter'];
const tabBar = tabs
.map((t) => {
const active = t === activeTab;
const cls = active
? 'px-3 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600'
: 'px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border-b-2 border-transparent transition-colors';
return `<a href="/applications/${app.id}?tab=${t}" class="${cls}" hx-get="/applications/${app.id}/tab/${t}" hx-target="#tab-content" hx-push-url="/applications/${app.id}?tab=${t}">${t.charAt(0).toUpperCase() + t.slice(1)}</a>`;
const label = t === 'cover-letter' ? 'Cover Letter' : t.charAt(0).toUpperCase() + t.slice(1);
return `<a href="/applications/${app.id}?tab=${t}" class="${cls}" hx-get="/applications/${app.id}/tab/${t}" hx-target="#tab-content" hx-push-url="/applications/${app.id}?tab=${t}">${label}</a>`;
})
.join('');

Expand Down Expand Up @@ -147,11 +152,45 @@ function renderTab(app: FullApp, tab: string): string {
return contactsSection(app.contacts, app.id);
case 'interviews':
return interviewsSection(app.interviewRounds, app.id);
case 'cover-letter':
return coverLetterTab(app.id);
default:
return overviewTab(app);
}
}

function coverLetterTab(appId: string): string {
const inputCls =
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none';
return `
<div class="space-y-4">
<div class="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<form hx-post="/applications/${appId}/cover-letter"
hx-target="#cover-letter-result"
hx-swap="innerHTML"
hx-indicator="#cover-letter-spinner"
class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Your resume / background
<span class="font-normal text-gray-400">(optional — paste for a tailored letter)</span>
</label>
<textarea name="resumeText" rows="6" placeholder="Paste your resume or relevant experience here…"
class="${inputCls}"></textarea>
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors htmx-active:opacity-60">
✨ Generate cover letter
</button>
<span id="cover-letter-spinner" class="htmx-indicator text-sm text-blue-500 animate-pulse">Generating…</span>
</div>
</form>
</div>
<div id="cover-letter-result"></div>
</div>`;
}

function overviewTab(app: FullApp): string {
return `
<div class="space-y-4">
Expand Down Expand Up @@ -232,6 +271,42 @@ export default async function applicationDetailRoutes(fastify: FastifyInstance):
}
});

// Cover letter generation
fastify.post('/applications/:id/cover-letter', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as { resumeText?: string };
const resumeText = body.resumeText?.trim() || null;
try {
const data = await authedGql<{ generateCoverLetter: string }>(
request,
reply,
GENERATE_COVER_LETTER,
{ applicationId: id, resumeText },
);
const text = escapeHtml(data.generateCoverLetter);
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>`);
Comment on lines +287 to +298

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.

} catch (err) {
if ((err as Error).message === 'Redirecting') return;
const msg = escapeHtml((err as Error).message || 'Failed to generate cover letter');
return reply
.type('text/html')
.send(
`<p class="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">${msg}</p>`,
);
}
});

// Status select — returns new select element
fastify.post('/applications/:id/status', async (request, reply) => {
const { id } = request.params as { id: string };
Expand Down
Loading
Loading