From ab565dbebec8e451071e1077b0fc5909efcdb15c Mon Sep 17 00:00:00 2001 From: Jeff Man Date: Tue, 21 Jul 2026 16:20:11 +0100 Subject: [PATCH] feat: add AI cover letter generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../GenerateCoverLetterUseCase.test.ts | 105 ++++++++++++++++++ apps/api/src/http/container.ts | 5 + apps/api/src/http/schema/index.ts | 1 + .../schema/mutations/coverLetterMutations.ts | 26 +++++ .../coverLetter/GenerateCoverLetterUseCase.ts | 52 +++++++++ apps/htmx/src/routes/applications/detail.ts | 79 ++++++++++++- .../applications/$applicationId/index.tsx | 101 ++++++++++++++++- 7 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts create mode 100644 apps/api/src/http/schema/mutations/coverLetterMutations.ts create mode 100644 apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts diff --git a/apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts b/apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts new file mode 100644 index 00000000..74b1b90e --- /dev/null +++ b/apps/api/src/__tests__/application/coverLetter/GenerateCoverLetterUseCase.test.ts @@ -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).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).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'); + }); +}); diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index 8bb2441b..d915145d 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -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'; @@ -159,6 +160,7 @@ declare module '@fastify/awilix' { transactionManager: PrismaTransactionManager; llmProvider: OpenRouterLLMProvider; parseJobDescriptionUseCase: ParseJobDescriptionUseCase; + generateCoverLetterUseCase: GenerateCoverLetterUseCase; } } @@ -273,5 +275,8 @@ export function buildContainer(fastify: FastifyInstance): void { parseJobDescriptionUseCase: asClass(ParseJobDescriptionUseCase, { lifetime: Lifetime.TRANSIENT, }), + generateCoverLetterUseCase: asClass(GenerateCoverLetterUseCase, { + lifetime: Lifetime.TRANSIENT, + }), }); } diff --git a/apps/api/src/http/schema/index.ts b/apps/api/src/http/schema/index.ts index 97a315f4..6756f0f6 100644 --- a/apps/api/src/http/schema/index.ts +++ b/apps/api/src/http/schema/index.ts @@ -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(); diff --git a/apps/api/src/http/schema/mutations/coverLetterMutations.ts b/apps/api/src/http/schema/mutations/coverLetterMutations.ts new file mode 100644 index 00000000..3b2b5631 --- /dev/null +++ b/apps/api/src/http/schema/mutations/coverLetterMutations.ts @@ -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'); + } + }, + }), +); diff --git a/apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts b/apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts new file mode 100644 index 00000000..c8633e8d --- /dev/null +++ b/apps/api/src/use-cases/coverLetter/GenerateCoverLetterUseCase.ts @@ -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 { + 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, + ); + } + + 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'); + } +} diff --git a/apps/htmx/src/routes/applications/detail.ts b/apps/htmx/src/routes/applications/detail.ts index 01c085b2..35806b51 100644 --- a/apps/htmx/src/routes/applications/detail.ts +++ b/apps/htmx/src/routes/applications/detail.ts @@ -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 = ` { 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 `${t.charAt(0).toUpperCase() + t.slice(1)}`; + const label = t === 'cover-letter' ? 'Cover Letter' : t.charAt(0).toUpperCase() + t.slice(1); + return `${label}`; }) .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 ` +
+
+
+
+ + +
+
+ + Generating… +
+
+
+
+
`; +} + function overviewTab(app: FullApp): string { return `
@@ -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(` +
+
+

Generated cover letter

+ +
+
${text}
+
`); + } 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( + `

${msg}

`, + ); + } + }); + // Status select — returns new select element fastify.post('/applications/:id/status', async (request, reply) => { const { id } = request.params as { id: string }; diff --git a/apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx b/apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx index dc4d8cd6..7916a27a 100644 --- a/apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx +++ b/apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx @@ -70,7 +70,7 @@ export function ApplicationDetailPage() { const [noteContent, setNoteContent] = useState(''); const [editingNote, setEditingNote] = useState(null); const [activeTab, setActiveTab] = useState< - 'notes' | 'interviews' | 'contacts' | 'activity' | 'documents' + 'notes' | 'interviews' | 'contacts' | 'activity' | 'documents' | 'cover letter' >('notes'); const { data: appData } = useQuery({ @@ -236,7 +236,9 @@ export function ApplicationDetailPage() {
- {(['notes', 'interviews', 'contacts', 'activity', 'documents'] as const).map((tab) => ( + {( + ['notes', 'interviews', 'contacts', 'activity', 'documents', 'cover letter'] as const + ).map((tab) => (
); } @@ -1382,3 +1386,96 @@ function DocumentsTab({ applicationId }: { applicationId: string }) { ); } + +const GENERATE_COVER_LETTER = ` + mutation GenerateCoverLetter($applicationId: ID!, $resumeText: String) { + generateCoverLetter(applicationId: $applicationId, resumeText: $resumeText) + } +`; + +function CoverLetterTab({ applicationId }: { applicationId: string }) { + const [resumeText, setResumeText] = useState(''); + const [coverLetter, setCoverLetter] = useState(''); + const [copied, setCopied] = useState(false); + + const generate = useMutation({ + mutationFn: () => + gqlClient.request<{ generateCoverLetter: string }>(GENERATE_COVER_LETTER, { + applicationId, + resumeText: resumeText.trim() || null, + }), + onSuccess: (data) => { + setCoverLetter(data.generateCoverLetter); + setCopied(false); + }, + }); + + const handleCopy = () => { + void navigator.clipboard.writeText(coverLetter); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const inputCls = + 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'; + + return ( +
+
+
+ +