-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add AI cover letter generator #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
| }); |
| 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'); | ||
| } | ||
| }, | ||
| }), | ||
| ); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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=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. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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'); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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(''); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Copy button is broken:
🐛 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } 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 }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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-L307apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx#L1454-L1458🤖 Prompt for AI Agents