feat: manage cover letters in a shared library (#3423)

* feat: add shared cover-letter library with resume styling

* fix: retain required sanitizer dependency in CI

* fix(cover-letters): prevent concurrent AI draft requests

* chore(codacy): exclude generated migrations

* fix(applications): keep Lingui macro out of callback dependencies
This commit is contained in:
Amruth Pillai
2026-09-05 10:01:22 -07:00
committed by GitHub
parent cce6d64afa
commit 1d4194a207
44 changed files with 14152 additions and 135 deletions
+3
View File
@@ -0,0 +1,3 @@
---
exclude_paths:
- "migrations/**"
+1
View File
@@ -80,6 +80,7 @@
"react-email": "^6.9.3",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sanitize-html": "^2.17.7",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
+225
View File
@@ -618,6 +618,10 @@ msgstr "API Reference"
msgid "App"
msgstr "App"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Application"
msgstr "Application"
#: src/features/applications/components/application-actions-menu.tsx
msgid "Application actions"
msgstr "Application actions"
@@ -783,6 +787,10 @@ msgstr "Attach a resume file (PDF)"
msgid "Attach files"
msgstr "Attach files"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Attach PDF"
msgstr "Attach PDF"
#: src/routes/agent/-components/agent-chat.tsx
msgid "Attachment"
msgstr "Attachment"
@@ -1007,6 +1015,10 @@ msgstr "Changed your mind? Rename your resume to something more descriptive."
msgid "Changelog"
msgstr "Changelog"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Changes are shared between your library and the resume builder."
msgstr "Changes are shared between your library and the resume builder."
#: src/routes/agent/-components/agent-chat.tsx
#: src/routes/agent/$threadId.tsx
msgid "Chat"
@@ -1056,6 +1068,10 @@ msgstr "Chinese (Simplified)"
msgid "Chinese (Traditional)"
msgstr "Chinese (Traditional)"
#: src/features/cover-letters/library.tsx
msgid "Choose a cover letter"
msgstr "Choose a cover letter"
#: src/routes/agent/-components/new-thread-setup.tsx
msgid "Choose a model and resume draft."
msgstr "Choose a model and resume draft."
@@ -1070,10 +1086,15 @@ msgstr "Choose a PDF"
msgid "Choose a preset or custom shade."
msgstr "Choose a preset or custom shade."
#: src/features/cover-letters/editor-dialog.tsx
#: src/routes/agent/-components/new-thread-setup.tsx
msgid "Choose a resume"
msgstr "Choose a resume"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Choose an application"
msgstr "Choose an application"
#: src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx
msgid "Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available."
msgstr "Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available."
@@ -1105,6 +1126,11 @@ msgstr "Click here to select a file to import"
msgid "Client ID"
msgstr "Client ID"
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/cover-letters/editor.tsx
msgid "Close"
msgstr "Close"
#: src/routes/agent/-components/agent-chat.tsx
msgid "Close AI assistant"
msgstr "Close AI assistant"
@@ -1218,6 +1244,7 @@ msgstr "Contacts"
#: src/dialogs/resume/sections/cover-letter.tsx
#: src/dialogs/resume/sections/summary-item.tsx
#: src/features/cover-letters/editor.tsx
msgid "Content"
msgstr "Content"
@@ -1265,6 +1292,10 @@ msgstr "Copied to clipboard."
msgid "Copy"
msgstr "Copy"
#: src/features/cover-letters/library.tsx
msgid "Copy an existing letter from this resume"
msgstr "Copy an existing letter from this resume"
#: src/dialogs/auth/enable-two-factor.tsx
msgid "Copy and store these backup codes in case you lose your device."
msgstr "Copy and store these backup codes in case you lose your device."
@@ -1277,6 +1308,10 @@ msgstr "Copy Backup Codes"
msgid "Copy JSON"
msgstr "Copy JSON"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Copy saved to your cover-letter library."
msgstr "Copy saved to your cover-letter library."
#. Accessible label for the button that copies the two-factor secret key
#: src/dialogs/auth/enable-two-factor.tsx
msgid "Copy secret"
@@ -1315,6 +1350,11 @@ msgstr "Correct the year, or write \"Present\" for ongoing work."
msgid "Could not complete this connection. Restart the connection from your client and try again."
msgstr "Could not complete this connection. Restart the connection from your client and try again."
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/cover-letters/library.tsx
msgid "Could not complete this action. Please try again."
msgstr "Could not complete this action. Please try again."
#: src/features/resume/export/use-resume-export.ts
msgid "Could not generate the DOCX. Please try again."
msgstr "Could not generate the DOCX. Please try again."
@@ -1324,6 +1364,18 @@ msgstr "Could not generate the DOCX. Please try again."
msgid "Could not generate the PDF. Please try again."
msgstr "Could not generate the PDF. Please try again."
#: src/features/cover-letters/library.tsx
msgid "Could not load cover letters."
msgstr "Could not load cover letters."
#: src/features/cover-letters/library.tsx
msgid "Could not load the selected resume."
msgstr "Could not load the selected resume."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Could not load this cover letter."
msgstr "Could not load this cover letter."
#: src/features/resume/export/use-resume-export.ts
msgid "Could not prepare your resume for printing. Please try again."
msgstr "Could not prepare your resume for printing. Please try again."
@@ -1337,6 +1389,10 @@ msgstr "Could not reach the AI provider. Please try again."
msgid "Could not restore this patch."
msgstr "Could not restore this patch."
#: src/features/cover-letters/editor.tsx
msgid "Could not save the cover letter. Please try again."
msgstr "Could not save the cover letter. Please try again."
#: src/dialogs/auth/enable-two-factor.tsx
msgid "Could not set up two-factor authentication."
msgstr "Could not set up two-factor authentication."
@@ -1396,6 +1452,7 @@ msgstr "Counted separately from the parse score. Coverage does not predict anyth
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-form-sheet.tsx
#: src/features/cover-letters/library.tsx
#: src/features/resume/export/download-dialog.tsx
msgid "Cover letter"
msgstr "Cover letter"
@@ -1413,6 +1470,16 @@ msgstr "Cover Letter"
msgid "Cover letter draft"
msgstr "Cover letter draft"
#: src/features/cover-letters/library.tsx
#: src/routes/dashboard/-components/sidebar.tsx
#: src/routes/dashboard/cover-letters.tsx
msgid "Cover letters"
msgstr "Cover letters"
#: src/features/cover-letters/library.tsx
msgid "Cover-letter library"
msgstr "Cover-letter library"
#. Create API key dialog submit action
#: src/dialogs/api-key/create.tsx
#: src/dialogs/resume/index.tsx
@@ -1431,6 +1498,7 @@ msgstr "Cover letter draft"
#: src/dialogs/resume/sections/skill.tsx
#: src/dialogs/resume/sections/summary-item.tsx
#: src/dialogs/resume/sections/volunteer.tsx
#: src/features/cover-letters/library.tsx
#: src/routes/dashboard/resumes/index.tsx
msgid "Create"
msgstr "Create"
@@ -1519,10 +1587,18 @@ msgstr "Create a new volunteer experience"
msgid "Create a Sample Resume"
msgstr "Create a Sample Resume"
#: src/features/cover-letters/library.tsx
msgid "Create and edit letters using your resume’s styling."
msgstr "Create and edit letters using your resume’s styling."
#: src/routes/_home/-sections/features.tsx
msgid "Create as many resumes as you want."
msgstr "Create as many resumes as you want."
#: src/features/cover-letters/library.tsx
msgid "Create cover letter"
msgstr "Create cover letter"
#: src/routes/agent/-components/new-thread-setup.tsx
msgid "Create from scratch"
msgstr "Create from scratch"
@@ -1656,6 +1732,10 @@ msgstr "Deep check"
msgid "DeepSeek"
msgstr "DeepSeek"
#: src/features/cover-letters/library.tsx
msgid "Default styling"
msgstr "Default styling"
#: src/dialogs/resume/sections/education.tsx
msgid "Degree"
msgstr "Degree"
@@ -1669,6 +1749,7 @@ msgstr "Degree"
#: src/features/applications/components/application-actions-menu.tsx
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/table-view.tsx
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/settings/authentication/components/passkeys.tsx
#: src/features/settings/pages/api-keys.tsx
#: src/routes/agent/-components/agent-chat.tsx
@@ -1712,6 +1793,10 @@ msgstr "Delete this agent thread?"
msgid "Delete this application?"
msgstr "Delete this application?"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Delete this cover letter?"
msgstr "Delete this cover letter?"
#: src/features/applications/components/application-detail-sheet.tsx
msgid "Delete this timeline entry?"
msgstr "Delete this timeline entry?"
@@ -1794,6 +1879,14 @@ msgstr "Disable Two-Factor Authentication"
msgid "Disabling two-factor authentication..."
msgstr "Disabling two-factor authentication..."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Discard"
msgstr "Discard"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Discard unsaved changes?"
msgstr "Discard unsaved changes?"
#. Authentication settings action to unlink a connected social login provider
#: src/features/settings/authentication/components/social-provider.tsx
msgid "Disconnect"
@@ -1862,6 +1955,7 @@ msgstr "Download CSV"
msgid "Download options"
msgstr "Download options"
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/resume/public/public-resume.tsx
#: src/routes/agent/-components/resume-pane.tsx
msgid "Download PDF"
@@ -1898,6 +1992,7 @@ msgstr "Drop the pronouns and start from the verb. That is the convention in mos
#. Resume card context menu action to create a copy
#. Resume card dropdown action to create a copy
#: src/dialogs/resume/index.tsx
#: src/features/cover-letters/editor-dialog.tsx
#: src/routes/builder/$resumeId/-components/header.tsx
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx
@@ -1937,6 +2032,11 @@ msgstr "Earlier versions are kept; the builder's undo history is reset."
msgid "Edit"
msgstr "Edit"
#. placeholder {0}: letter.name
#: src/features/cover-letters/library.tsx
msgid "Edit {0}"
msgstr "Edit {0}"
#. Screen reader label for button that edits a keyword chip. Variable is the current keyword text.
#: src/components/input/chip-input.tsx
msgid "Edit {chip}"
@@ -1956,6 +2056,10 @@ msgstr "Edit color {0}"
msgid "Edit content in fullscreen mode"
msgstr "Edit content in fullscreen mode"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Edit cover letter"
msgstr "Edit cover letter"
#: src/features/applications/components/application-detail-sheet.tsx
msgid "Edit date"
msgstr "Edit date"
@@ -2056,6 +2160,10 @@ msgstr "English"
msgid "English (United Kingdom)"
msgstr "English (United Kingdom)"
#: src/features/cover-letters/editor.tsx
msgid "Enter a name for this cover letter."
msgstr "Enter a name for this cover letter."
#: src/features/settings/authentication/components/passkeys.tsx
msgid "Enter a name for your passkey."
msgstr "Enter a name for your passkey."
@@ -2155,6 +2263,10 @@ msgstr "Export Data"
msgid "Export from the original document instead of scanning or screenshotting it."
msgstr "Export from the original document instead of scanning or screenshotting it."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Export JSON"
msgstr "Export JSON"
#: src/features/settings/pages/account.tsx
msgid "Export my data"
msgstr "Export my data"
@@ -2677,6 +2789,10 @@ msgstr "Hide password"
msgid "Hide picture"
msgstr "Hide picture"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Hide preview"
msgstr "Hide preview"
#: src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx
msgid "Hide Section Icons"
msgstr "Hide Section Icons"
@@ -2754,6 +2870,10 @@ msgstr "Import {0}"
msgid "Import an existing resume"
msgstr "Import an existing resume"
#: src/features/cover-letters/library.tsx
msgid "Import cover letter JSON"
msgstr "Import cover letter JSON"
#: src/routes/dashboard/applications/index.tsx
msgid "Import CSV"
msgstr "Import CSV"
@@ -2767,6 +2887,10 @@ msgstr "Import failed. Check the CSV and try again."
msgid "Import from CSV"
msgstr "Import from CSV"
#: src/features/cover-letters/library.tsx
msgid "Import JSON"
msgstr "Import JSON"
#. placeholder {0}: result.imported
#: src/features/applications/components/import-applications-sheet.tsx
msgid "Imported {0} application(s)."
@@ -2950,6 +3074,10 @@ msgstr "Keep a single address, so software does not have to guess which one to u
msgid "Keep access when you are not using the application."
msgstr "Keep access when you are not using the application."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Keep editing"
msgstr "Keep editing"
#: src/features/ats-checker/messages.ts
msgid "Keep it under 2.5 MB, usually by removing or shrinking images."
msgstr "Keep it under 2.5 MB, usually by removing or shrinking images."
@@ -3386,6 +3514,8 @@ msgstr "Must start with https://"
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/ats-checker/parse-preview.tsx
#: src/features/auth/pages/register.tsx
#: src/features/cover-letters/editor.tsx
#: src/features/cover-letters/library.tsx
#: src/features/settings/pages/profile.tsx
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
#: src/routes/dashboard/resumes/index.tsx
@@ -3462,6 +3592,10 @@ msgstr "New thread"
msgid "New Thread"
msgstr "New Thread"
#: src/features/cover-letters/library.tsx
msgid "Next"
msgstr "Next"
#: src/routes/_home/-sections/features.tsx
msgid "No ads and no trackers, so nothing gets in your way."
msgstr "No ads and no trackers, so nothing gets in your way."
@@ -3495,6 +3629,10 @@ msgstr "No commands match that search."
msgid "No conventional section headings were found."
msgstr "No conventional section headings were found."
#: src/features/cover-letters/library.tsx
msgid "No cover letters found. Create one or import an existing letter."
msgstr "No cover letters found. Create one or import an existing letter."
#. Error shown when AI import endpoint returns no parsed resume data
#: src/dialogs/resume/import.tsx
msgid "No data was returned from the AI provider."
@@ -3789,8 +3927,10 @@ msgid "Page"
msgstr "Page"
#. Layout editor page label with 1-based page number
#. placeholder {0}: Math.floor(offset / 20) + 1
#. placeholder {0}: pageIndex + 1
#. placeholder {0}: targetPageIndex + 1
#: src/features/cover-letters/library.tsx
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx
msgid "Page {0}"
@@ -3916,6 +4056,14 @@ msgstr "Patch rolled back."
msgid "PDF"
msgstr "PDF"
#: src/features/cover-letters/editor-dialog.tsx
msgid "PDF snapshot attached to the application."
msgstr "PDF snapshot attached to the application."
#: src/features/cover-letters/editor-dialog.tsx
msgid "PDFs already attached to applications will remain available."
msgstr "PDFs already attached to applications will remain available."
#: src/dialogs/resume/sections/education.tsx
#: src/dialogs/resume/sections/experience.tsx
#: src/dialogs/resume/sections/project.tsx
@@ -4051,6 +4199,14 @@ msgstr "Preview and export fall back to base styles."
msgid "Preview and export keep valid styles and ignore invalid styles."
msgstr "Preview and export keep valid styles and ignore invalid styles."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Preview PDF"
msgstr "Preview PDF"
#: src/features/cover-letters/library.tsx
msgid "Previous"
msgstr "Previous"
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
msgid "Primary Color"
msgstr "Primary Color"
@@ -4241,6 +4397,7 @@ msgid "Ready to activate with warnings"
msgstr "Ready to activate with warnings"
#: src/dialogs/resume/sections/cover-letter.tsx
#: src/features/cover-letters/editor.tsx
msgid "Recipient"
msgstr "Recipient"
@@ -4270,10 +4427,18 @@ msgstr "References"
msgid "Referred by…, things to emphasize, etc."
msgstr "Referred by…, things to emphasize, etc."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Refresh from resume"
msgstr "Refresh from resume"
#: src/features/settings/authentication/components/passkeys.tsx
msgid "Register New Device"
msgstr "Register New Device"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Reload latest version"
msgstr "Reload latest version"
#: src/features/auth/pages/forgot-password.tsx
msgid "Remember your password? <0/>"
msgstr "Remember your password? <0/>"
@@ -4340,6 +4505,10 @@ msgstr "Reorder custom field"
msgid "Repeated far more often than the posting itself uses them: {0}. Recruiters notice."
msgstr "Repeated far more often than the posting itself uses them: {0}. Recruiters notice."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Replace"
msgstr "Replace"
#: src/features/ats-checker/messages.ts
msgid "Replace icon fonts with plain text, then re-export."
msgstr "Replace icon fonts with plain text, then re-export."
@@ -4348,6 +4517,10 @@ msgstr "Replace icon fonts with plain text, then re-export."
msgid "Replace tables with plain paragraphs and lists. Cells extract in storage order, not reading order."
msgstr "Replace tables with plain paragraphs and lists. Cells extract in storage order, not reading order."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Replace the attached cover letter?"
msgstr "Replace the attached cover letter?"
#: src/routes/builder/$resumeId/-sidebar/right/sections/information.tsx
msgid "Report a Bug"
msgstr "Report a Bug"
@@ -4438,6 +4611,11 @@ msgstr "Resume options"
msgid "Resume patch"
msgstr "Resume patch"
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/cover-letters/library.tsx
msgid "Resume styling"
msgstr "Resume styling"
#: src/dialogs/resume/template/gallery.tsx
msgid "Resume templates for different professions and tastes. Pick the one that suits you."
msgstr "Resume templates for different professions and tastes. Pick the one that suits you."
@@ -4450,6 +4628,8 @@ msgstr "Resume templates for different professions and tastes. Pick the one that
msgid "Resumes"
msgstr "Resumes"
#: src/features/cover-letters/editor-dialog.tsx
#: src/features/cover-letters/library.tsx
#: src/routes/agent/-components/agent-chat.tsx
msgid "Retry"
msgstr "Retry"
@@ -4577,14 +4757,27 @@ msgstr "Save changes"
#: src/dialogs/resume/sections/skill.tsx
#: src/dialogs/resume/sections/summary-item.tsx
#: src/dialogs/resume/sections/volunteer.tsx
#: src/features/cover-letters/editor.tsx
#: src/features/settings/pages/profile.tsx
msgid "Save Changes"
msgstr "Save Changes"
#: src/features/cover-letters/editor.tsx
msgid "Save changes before previewing, exporting, or changing styling."
msgstr "Save changes before previewing, exporting, or changing styling."
#: src/features/cover-letters/library.tsx
msgid "Save copy to library"
msgstr "Save copy to library"
#: src/features/settings/integrations/components/ai-section.tsx
msgid "Save model"
msgstr "Save model"
#: src/features/cover-letters/library.tsx
msgid "Save resume changes before copying its content or styling."
msgstr "Save resume changes before copying its content or styling."
#: src/features/ats-checker/messages.ts
msgid "Save the file with every page upright."
msgstr "Save the file with every page upright."
@@ -4626,6 +4819,14 @@ msgstr "Search applications…"
msgid "Search commands"
msgstr "Search commands"
#: src/features/cover-letters/library.tsx
msgid "Search cover letters"
msgstr "Search cover letters"
#: src/features/cover-letters/library.tsx
msgid "Search cover letters..."
msgstr "Search cover letters..."
#. Accessible label for icon picker search input
#. Placeholder text in icon picker search input
#: src/components/input/icon-picker.tsx
@@ -5211,6 +5412,14 @@ msgstr "Stylesheet editor"
msgid "Stylesheet has fatal errors"
msgstr "Stylesheet has fatal errors"
#: src/features/cover-letters/editor-dialog.tsx
msgid "Styling and sender details are copied. Future resume edits apply only when you refresh."
msgstr "Styling and sender details are copied. Future resume edits apply only when you refresh."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Styling and sender details refreshed from the selected resume."
msgstr "Styling and sender details refreshed from the selected resume."
#: src/routes/_home/-sections/footer.tsx
msgid "Subreddit"
msgstr "Subreddit"
@@ -5411,6 +5620,10 @@ msgstr "The imported file could not be parsed into a valid resume."
msgid "The order the text is stored in disagrees badly with the order it reads in."
msgstr "The order the text is stored in disagrees badly with the order it reads in."
#: src/features/cover-letters/library.tsx
msgid "The original letter stays in your resume. Each copy can be edited independently."
msgstr "The original letter stays in your resume. Each copy can be edited independently."
#: src/components/layout/not-found-screen.tsx
msgid "The page you're looking for may have been moved or no longer exists."
msgstr "The page you're looking for may have been moved or no longer exists."
@@ -5544,6 +5757,10 @@ msgstr "This check only runs on resumes written in English."
msgid "This connection request is invalid or has expired."
msgstr "This connection request is invalid or has expired."
#: src/features/cover-letters/editor.tsx
msgid "This cover letter changed elsewhere. Your edits are still here. Reload the latest version before saving again."
msgstr "This cover letter changed elsewhere. Your edits are still here. Reload the latest version before saving again."
#: src/dialogs/api-key/create.tsx
msgid "This creates a new API key, which lets other programs read and change your resume data through the Reactive Resume API."
msgstr "This creates a new API key, which lets other programs read and change your resume data through the Reactive Resume API."
@@ -5676,6 +5893,10 @@ msgstr "This period ends before it starts."
msgid "This period starts in the future."
msgstr "This period starts in the future."
#: src/features/cover-letters/library.tsx
msgid "This resume has no embedded cover letters."
msgstr "This resume has no embedded cover letters."
#: src/routes/builder/$resumeId/-sidebar/left/index.tsx
msgid "This resume is locked"
msgstr "This resume is locked"
@@ -6668,6 +6889,10 @@ msgstr "Your resume uses \"I\" and \"my\"."
msgid "Your resume's public URL is protected by a password. Share the password only with people you trust."
msgstr "Your resume's public URL is protected by a password. Share the password only with people you trust."
#: src/features/cover-letters/editor-dialog.tsx
msgid "Your saved cover letter will remain unchanged."
msgstr "Your saved cover letter will remain unchanged."
#: src/routes/_home/-sections/donate.tsx
msgid "Your support keeps the project free and open to everyone, now and later."
msgstr "Your support keeps the project free and open to everyone, now and later."
+11 -1
View File
@@ -89,6 +89,7 @@ const extensions = [
];
type Props = UseEditorOptions & {
"aria-label"?: string;
value: string;
onChange: (value: string) => void;
style?: React.CSSProperties;
@@ -96,7 +97,15 @@ type Props = UseEditorOptions & {
editorClassName?: string;
};
export function RichInput({ value, onChange, style, className, editorClassName, ...options }: Props) {
export function RichInput({
value,
onChange,
style,
className,
editorClassName,
"aria-label": ariaLabel,
...options
}: Props) {
const { i18n } = useLingui();
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -110,6 +119,7 @@ export function RichInput({ value, onChange, style, className, editorClassName,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
spellcheck: "false",
"data-editor": "true",
"data-fullscreen": isFullscreen ? "true" : "false",
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import type { Application } from "../types";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({ draft: vi.fn(), other: vi.fn() }));
type MockEditorDialogProps = { letterId: string };
vi.mock("@/libs/orpc/client", () => ({
orpc: {
applications: {
ai: {
matchScore: { mutationOptions: (options: object) => ({ ...options, mutationFn: mocks.other }) },
tailorResume: { mutationOptions: (options: object) => ({ ...options, mutationFn: mocks.other }) },
draftMessage: { mutationOptions: (options: object) => ({ ...options, mutationFn: mocks.draft }) },
},
},
coverLetters: { list: { key: () => ["cover-letters"] } },
},
}));
vi.mock("@/features/cover-letters/editor-dialog", () => ({
CoverLetterEditorDialog: ({ letterId }: MockEditorDialogProps) => <div role="dialog">Saved letter {letterId}</div>,
}));
const { ApplicationAiCopilot } = await import("./application-ai-copilot");
const application: Application = {
id: "application",
company: "Example",
role: "Engineer",
location: null,
salary: null,
status: "saved",
archived: false,
resumeId: null,
source: null,
sourceUrl: null,
jobDescription: null,
matchScore: null,
aiMetadata: null,
notes: null,
resumeFileUrl: null,
resumeFileName: null,
coverLetterUrl: null,
coverLetterName: null,
followUpAt: null,
followUpNote: null,
tags: [],
contacts: [],
activity: [],
appliedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => vi.resetAllMocks());
function draftRequest() {
let resolve!: (result: { text: string; coverLetterId?: string }) => void;
const promise = new Promise<{ text: string; coverLetterId?: string }>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
function renderCopilot() {
return render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
<I18nProvider i18n={i18n}>
<ApplicationAiCopilot application={application} />
</I18nProvider>
</QueryClientProvider>,
);
}
it.each(["cover-letter", "follow-up"] as const)(
"blocks both draft actions while the first %s is pending",
async (kind) => {
const request = draftRequest();
mocks.draft.mockReturnValue(request.promise);
renderCopilot();
const coverLetter = screen.getByRole("button", { name: /Draft a cover letter/ });
const followUp = screen.getByRole("button", { name: /Draft a follow-up/ });
await userEvent.click(kind === "cover-letter" ? coverLetter : followUp);
await waitFor(() => expect(mocks.draft).toHaveBeenCalledTimes(1));
expect(coverLetter).toBeDisabled();
expect(followUp).toBeDisabled();
await userEvent.click(coverLetter);
await userEvent.click(followUp);
expect(mocks.draft).toHaveBeenCalledTimes(1);
await act(async () =>
request.resolve(
kind === "cover-letter" ? { text: "Letter", coverLetterId: "letter-one" } : { text: "Follow-up" },
),
);
if (kind === "cover-letter") expect(await screen.findByRole("dialog")).toHaveTextContent("letter-one");
else expect(await screen.findByText("Follow-up")).toBeVisible();
expect(coverLetter).toBeEnabled();
expect(followUp).toBeEnabled();
},
);
it("prevents duplicate saved letters after an earlier follow-up completed", async () => {
mocks.draft.mockResolvedValueOnce({ text: "Earlier follow-up" });
const request = draftRequest();
mocks.draft.mockReturnValueOnce(request.promise);
renderCopilot();
const coverLetter = screen.getByRole("button", { name: /Draft a cover letter/ });
const followUp = screen.getByRole("button", { name: /Draft a follow-up/ });
await userEvent.click(followUp);
await screen.findByText("Earlier follow-up");
await userEvent.click(coverLetter);
await waitFor(() => expect(mocks.draft).toHaveBeenCalledTimes(2));
expect(coverLetter).toBeDisabled();
expect(followUp).toBeDisabled();
await userEvent.click(coverLetter);
expect(mocks.draft).toHaveBeenCalledTimes(2);
await act(async () => request.resolve({ text: "Saved letter", coverLetterId: "letter-two" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("letter-two");
expect(screen.queryByText("Earlier follow-up")).not.toBeInTheDocument();
});
@@ -1,4 +1,3 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -7,71 +6,19 @@ import {
CaretRightIcon,
CopyIcon,
EnvelopeSimpleIcon,
FilePdfIcon,
MagicWandIcon,
PaperPlaneTiltIcon,
SparkleIcon,
SpinnerGapIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { toast } from "@reactive-resume/ui/components/toast";
import { generateFilename } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { CoverLetterEditorDialog } from "@/features/cover-letters/editor-dialog";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
const COPILOT_COVER_LETTER_SECTION_ID = "copilot-cover-letter";
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const toHtmlParagraphs = (value: string) =>
value
.trim()
.split(/\n\s*\n/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br />")}</p>`)
.join("");
function createCoverLetterPdfData(data: ResumeData, text: string): ResumeData {
return {
...data,
customSections: [
{
id: COPILOT_COVER_LETTER_SECTION_ID,
type: "cover-letter",
title: "",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: `${COPILOT_COVER_LETTER_SECTION_ID}-item`,
hidden: false,
recipient: "",
content: toHtmlParagraphs(text),
},
],
},
],
metadata: {
...data.metadata,
layout: {
...data.metadata.layout,
pages: [{ fullWidth: true, main: [COPILOT_COVER_LETTER_SECTION_ID], sidebar: [] }],
},
},
};
}
// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage.
function band(score: number) {
if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
@@ -156,11 +103,7 @@ type Props = { application: Application };
export function ApplicationAiCopilot({ application }: Props) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
const [isGeneratingCoverLetter, setIsGeneratingCoverLetter] = useState(false);
const { data: resume } = useQuery({
...orpc.resume.getById.queryOptions({ input: { id: application.resumeId ?? "" } }),
enabled: !!application.resumeId,
});
const [coverLetterId, setCoverLetterId] = useState<string | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
@@ -186,56 +129,22 @@ export function ApplicationAiCopilot({ application }: Props) {
);
const draftMessage = useMutation(
orpc.applications.ai.draftMessage.mutationOptions({
onSuccess: (result, variables) => setDraft({ kind: variables.kind, text: result.text }),
onSuccess: (result, variables) => {
if (result.coverLetterId) {
setDraft(null);
setCoverLetterId(result.coverLetterId);
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
} else {
setDraft({ kind: variables.kind, text: result.text });
}
},
onError: (error) => toast.add({ type: "error", description: error.message || t`Drafting failed.` }),
}),
);
const attachCoverLetter = useMutation(
orpc.applications.attachDocument.mutationOptions({
onSuccess: () => {
invalidate();
toast.add({ type: "success", description: t`Cover letter PDF attached to this application.` });
},
onError: (error) =>
toast.add({ type: "error", description: error.message || t`Could not attach the cover letter PDF.` }),
}),
);
const pending =
matchScore.isPending || tailorResume.isPending || draftMessage.isPending || attachCoverLetter.isPending;
const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending;
const canScore = !!application.resumeId && !!application.jobDescription;
const score = application.matchScore;
const gaps = aiGaps(application);
const attachDraftAsPdf = async () => {
if (draft?.kind !== "cover-letter" || isGeneratingCoverLetter) return;
if (!resume) {
toast.add({ type: "error", description: t`Link a resume to generate a cover letter PDF.` });
return;
}
setIsGeneratingCoverLetter(true);
let file: File;
try {
const blob = await createResumePdfBlob(createCoverLetterPdfData(resume.data, draft.text));
file = new File([blob], generateFilename(`Cover Letter - ${application.company || "Untitled"}`, "pdf"), {
type: "application/pdf",
});
} catch {
toast.add({ type: "error", description: t`Could not generate the cover letter PDF.` });
setIsGeneratingCoverLetter(false);
return;
}
try {
await attachCoverLetter.mutateAsync({ id: application.id, kind: "cover-letter", file });
} catch {
// Mutation onError displays the upload failure.
} finally {
setIsGeneratingCoverLetter(false);
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: keep review-requested callback dependencies explicit.
const copyDraft = useCallback(async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
@@ -247,7 +156,7 @@ export function ApplicationAiCopilot({ application }: Props) {
description: t`Could not copy to clipboard. Please copy the text manually.`,
});
}
}, [draft?.text, toast, t]);
}, [draft?.text]);
return (
<section className="overflow-hidden rounded-xl border border-primary/15 bg-primary/[0.04]">
@@ -333,14 +242,16 @@ export function ApplicationAiCopilot({ application }: Props) {
icon={<EnvelopeSimpleIcon />}
title={<Trans>Draft a cover letter</Trans>}
description={t`From your resume and the posting`}
pending={draftMessage.isPending && draft?.kind !== "follow-up"}
disabled={draftMessage.isPending}
pending={draftMessage.isPending && draftMessage.variables?.kind === "cover-letter"}
onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })}
/>
<ActionRow
icon={<PaperPlaneTiltIcon />}
title={<Trans>Draft a follow-up</Trans>}
description={t`A friendly nudge for the recruiter`}
pending={draftMessage.isPending && draft?.kind === "follow-up"}
disabled={draftMessage.isPending}
pending={draftMessage.isPending && draftMessage.variables?.kind === "follow-up"}
onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })}
/>
</div>
@@ -359,23 +270,6 @@ export function ApplicationAiCopilot({ application }: Props) {
>
<CopyIcon className="size-3.5" /> <Trans>Copy</Trans>
</button>
{draft.kind === "cover-letter" && (
<button
type="button"
disabled={isGeneratingCoverLetter || attachCoverLetter.isPending}
className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground disabled:opacity-50"
onClick={() => void attachDraftAsPdf()}
>
<FilePdfIcon className="size-3.5" />
{isGeneratingCoverLetter ? (
<Trans>Generating PDF…</Trans>
) : attachCoverLetter.isPending ? (
<Trans>Attaching…</Trans>
) : (
<Trans>Generate PDF & attach</Trans>
)}
</button>
)}
<button
type="button"
className="text-muted-foreground text-xs hover:text-foreground"
@@ -390,6 +284,7 @@ export function ApplicationAiCopilot({ application }: Props) {
</p>
</div>
)}
{coverLetterId && <CoverLetterEditorDialog letterId={coverLetterId} onClose={() => setCoverLetterId(null)} />}
</section>
);
}
@@ -0,0 +1,366 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ClientOnly, useBlocker } from "@tanstack/react-router";
import { lazy, Suspense, useCallback, useId, useMemo, useRef, useState } from "react";
import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { Combobox } from "@/components/ui/combobox";
import { useConfirm } from "@/hooks/use-confirm";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { CoverLetterEditor } from "./editor";
const PdfViewer = lazy(() =>
import("@/features/resume/public/pdf-viewer").then((module) => ({ default: module.PdfViewer })),
);
type CoverLetterEditorDialogProps = {
letterId: string;
onClose: () => void;
activeResumeId?: string;
resumeReady?: boolean;
};
export function CoverLetterEditorDialog({
letterId,
onClose,
activeResumeId,
resumeReady = true,
}: CoverLetterEditorDialogProps) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [editState, setEditState] = useState({ dirty: false, pending: false });
const [reloadVersion, setReloadVersion] = useState(0);
const [busy, setBusy] = useState(false);
const running = useRef(false);
const query = useQuery(orpc.coverLetters.getById.queryOptions({ input: { id: letterId } }));
const onDirtyChange = useCallback((dirty: boolean, pending: boolean) => setEditState({ dirty, pending }), []);
const canClose = () => {
if (editState.pending || running.current) return false;
if (!editState.dirty) return true;
return confirm(t`Discard unsaved changes?`, {
description: t`Your saved cover letter will remain unchanged.`,
confirmText: t`Discard`,
cancelText: t`Keep editing`,
});
};
const requestClose = async () => {
if (await canClose()) onClose();
};
useBlocker({
shouldBlockFn: async () => !(await canClose()),
enableBeforeUnload: editState.dirty || editState.pending || busy,
});
const remember = (letter: CoverLetter) => {
queryClient.setQueryData(orpc.coverLetters.getById.queryKey({ input: { id: letter.id } }), letter);
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
};
const run = async (action: () => Promise<void>) => {
if (running.current) return;
running.current = true;
setBusy(true);
try {
await action();
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not complete this action. Please try again.`),
});
} finally {
running.current = false;
setBusy(false);
}
};
const reload = async () => {
if (!(await canClose())) return;
await run(async () => {
remember(await orpc.coverLetters.getById.call({ id: letterId }));
setReloadVersion((version) => version + 1);
});
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) void requestClose();
}}
>
<DialogContent className="lg:max-w-3xl xl:max-w-4xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>
<Trans>Edit cover letter</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Changes are shared between your library and the resume builder.</Trans>
</DialogDescription>
</DialogHeader>
{query.isPending ? (
<Spinner />
) : query.error ? (
<div className="space-y-3">
<p role="alert">{getReadableErrorMessage(query.error, t`Could not load this cover letter.`)}</p>
<Button onClick={() => void query.refetch()}>
<Trans>Retry</Trans>
</Button>
<Button variant="outline" onClick={onClose}>
<Trans>Close</Trans>
</Button>
</div>
) : (
<>
<CoverLetterEditor
key={`${letterId}:${reloadVersion}`}
letter={query.data}
disabled={busy}
onClose={() => void requestClose()}
onDirtyChange={onDirtyChange}
onSave={async (changes) => {
const updated = await orpc.coverLetters.update.call({ id: letterId, ...changes });
remember(updated);
return updated;
}}
actions={(letter, disabled) => (
<CoverLetterActions
letter={letter}
activeResumeId={activeResumeId}
resumeReady={resumeReady}
disabled={disabled || busy}
run={run}
onUpdated={remember}
onDeleted={onClose}
/>
)}
/>
<Button variant="ghost" disabled={editState.pending || busy} onClick={() => void reload()}>
<Trans>Reload latest version</Trans>
</Button>
</>
)}
</DialogContent>
</Dialog>
);
}
type CoverLetterActionsProps = {
letter: CoverLetter;
activeResumeId?: string;
resumeReady: boolean;
disabled: boolean;
run: (action: () => Promise<void>) => Promise<void>;
onUpdated: (letter: CoverLetter) => void;
onDeleted: () => void;
};
function CoverLetterActions({
letter,
activeResumeId,
resumeReady,
disabled,
run,
onUpdated,
onDeleted,
}: CoverLetterActionsProps) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const styleId = useId();
const applicationId = useId();
const [resumeId, setResumeId] = useState<string | null>(letter.sourceResumeId);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(letter.sourceApplicationId);
const [preview, setPreview] = useState(false);
const sourceReady = resumeId !== activeResumeId || resumeReady;
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const applications = useQuery(orpc.applications.list.queryOptions({ input: { includeArchived: false } }));
const data = useMemo(() => createCoverLetterResumeData(letter), [letter]);
const createPdf = async () => {
const { createResumePdfBlob } = await import("@/features/resume/export/pdf-document");
return createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true });
};
return (
<div className="space-y-4">
<Separator />
<fieldset disabled={disabled} className="min-w-0 space-y-4">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setPreview(!preview)}>
{preview ? <Trans>Hide preview</Trans> : <Trans>Preview PDF</Trans>}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
void run(async () => downloadWithAnchor(await createPdf(), generateFilename(letter.name, "pdf")))
}
>
<Trans>Download PDF</Trans>
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
void run(async () => {
const document = await orpc.coverLetters.export.call({ id: letter.id });
downloadWithAnchor(
new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }),
generateFilename(letter.name, "json"),
);
})
}
>
<Trans>Export JSON</Trans>
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
void run(async () => {
await orpc.coverLetters.duplicate.call({ id: letter.id });
await queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
toast.add({ type: "success", description: t`Copy saved to your cover-letter library.` });
})
}
>
<Trans>Duplicate</Trans>
</Button>
<Button
type="button"
variant="destructive"
onClick={() =>
void run(async () => {
if (
!(await confirm(t`Delete this cover letter?`, {
description: t`PDFs already attached to applications will remain available.`,
confirmText: t`Delete`,
}))
)
return;
await orpc.coverLetters.delete.call({ id: letter.id, expectedRevision: letter.revision });
await queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
onDeleted();
})
}
>
<Trans>Delete</Trans>
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={styleId}>
<Trans>Resume styling</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
id={styleId}
className="min-w-48 flex-1"
disabled={disabled}
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={setResumeId}
placeholder={t`Choose a resume`}
/>
<Button
type="button"
variant="outline"
disabled={disabled || !resumeId || !sourceReady}
onClick={() =>
void run(async () => {
if (!resumeId || !sourceReady) return;
onUpdated(
await orpc.coverLetters.refreshStyle.call({
id: letter.id,
expectedRevision: letter.revision,
resumeId,
}),
);
toast.add({
type: "success",
description: t`Styling and sender details refreshed from the selected resume.`,
});
})
}
>
<Trans>Refresh from resume</Trans>
</Button>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Styling and sender details are copied. Future resume edits apply only when you refresh.</Trans>
</p>
{!sourceReady && (
<p role="status" className="text-muted-foreground text-sm">
<Trans>Save resume changes before copying its content or styling.</Trans>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor={applicationId}>
<Trans>Application</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
id={applicationId}
className="min-w-48 flex-1"
disabled={disabled}
options={(applications.data ?? []).map((application) => ({
value: application.id,
label: `${application.company} — ${application.role}`,
}))}
value={selectedApplicationId}
onValueChange={setSelectedApplicationId}
placeholder={t`Choose an application`}
/>
<Button
type="button"
variant="outline"
disabled={disabled || !selectedApplicationId}
onClick={() =>
void run(async () => {
if (!selectedApplicationId) return;
const application = applications.data?.find((item) => item.id === selectedApplicationId);
if (
application?.coverLetterUrl &&
!(await confirm(t`Replace the attached cover letter?`, { confirmText: t`Replace` }))
)
return;
const file = new File([await createPdf()], generateFilename(letter.name, "pdf"), {
type: "application/pdf",
});
await orpc.applications.attachDocument.call({
id: selectedApplicationId,
kind: "cover-letter",
file,
});
await queryClient.invalidateQueries({ queryKey: orpc.applications.key() });
toast.add({ type: "success", description: t`PDF snapshot attached to the application.` });
})
}
>
<Trans>Attach PDF</Trans>
</Button>
</div>
</div>
</fieldset>
{preview && (
<ClientOnly fallback={<Spinner />}>
<Suspense fallback={<Spinner />}>
<PdfViewer data={data} includeCoverLetterHeader />
</Suspense>
</ClientOnly>
)}
</div>
);
}
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ReactNode } from "react";
import { cleanup, fireEvent, render as renderComponent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ORPCError } from "@orpc/client";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { CoverLetterEditor } from "./editor";
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
afterEach(cleanup);
type WrapperProps = { children: ReactNode };
function Wrapper({ children }: WrapperProps) {
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}
const render = (ui: ReactNode) => renderComponent(ui, { wrapper: Wrapper });
vi.mock("@/components/input/rich-input", () => ({
RichInput: ({
value,
onChange,
"aria-label": label,
editable,
}: {
value: string;
onChange: (value: string) => void;
"aria-label": string;
editable?: boolean;
}) => (
<textarea
aria-label={label}
value={value}
disabled={editable === false}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
const { notes: _notes, layout: _layout, ...metadata } = defaultResumeData.metadata;
const letter: CoverLetter = {
id: "letter-1",
name: "Engineer application",
recipient: "Hiring team",
content: "Original letter",
style: {
basics: defaultResumeData.basics,
picture: defaultResumeData.picture,
metadata,
sectionId: "section-1",
itemId: "item-1",
},
sourceResumeId: null,
sourceApplicationId: null,
revision: 3,
createdAt: new Date("2026-09-05T12:00:00Z"),
updatedAt: new Date("2026-09-05T12:00:00Z"),
};
describe("CoverLetterEditor", () => {
it("keeps edits after a failed save", async () => {
const onSave = vi.fn().mockRejectedValue(new Error("Save failed"));
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My unsaved draft" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await screen.findByRole("alert");
expect(screen.getByLabelText("Content")).toHaveValue("My unsaved draft");
expect(screen.getByRole("alert")).toHaveTextContent("Save failed");
});
it("saves current content with original revision and uses returned revision for the next edit", async () => {
const onSave = vi.fn().mockImplementation(async (changes) => ({ ...letter, ...changes, revision: 4 }));
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "New letter" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled());
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated name" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
expect(onSave.mock.calls[0]?.[0]).toMatchObject({ content: "New letter", expectedRevision: 3 });
expect(onSave.mock.calls[1]?.[0]).toMatchObject({
name: "Updated name",
content: "New letter",
expectedRevision: 4,
});
});
it("keeps local input and revision when another editor changed the document", async () => {
const onSave = vi.fn().mockRejectedValue(new ORPCError("CONFLICT"));
const view = render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Local draft" } });
view.rerender(
<CoverLetterEditor
letter={{ ...letter, content: "Other editor", revision: 4 }}
onSave={onSave}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await screen.findByRole("alert");
expect(screen.getByLabelText("Content")).toHaveValue("Local draft");
expect(onSave.mock.calls[0]?.[0]).toMatchObject({ expectedRevision: 3 });
expect(screen.getByRole("alert")).toHaveTextContent(/changed elsewhere/i);
});
it("disables editing and duplicate submission while saving", async () => {
let resolveSave!: (result: CoverLetter) => void;
const onSave = vi.fn(
() =>
new Promise<CoverLetter>((resolve) => {
resolveSave = resolve;
}),
);
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Pending letter" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByLabelText("Name")).toBeDisabled();
expect(screen.getByLabelText("Content")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
expect(onSave).toHaveBeenCalledTimes(1);
resolveSave({ ...letter, content: "Pending letter", revision: 4 });
await waitFor(() => expect(screen.getByLabelText("Name")).not.toBeDisabled());
});
});
@@ -0,0 +1,147 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { RichInput } from "@/components/input/rich-input";
import { getReadableErrorMessage } from "@/libs/error-message";
type CoverLetterChanges = Pick<CoverLetter, "name" | "recipient" | "content"> & { expectedRevision: number };
export type CoverLetterEditorProps = {
letter: CoverLetter;
disabled?: boolean;
onSave: (changes: CoverLetterChanges) => Promise<CoverLetter>;
onClose: () => void;
onDirtyChange?: (dirty: boolean, pending: boolean) => void;
actions?: (letter: CoverLetter, disabled: boolean) => ReactNode;
};
const editableFields = (letter: CoverLetter) => ({
name: letter.name,
recipient: letter.recipient,
content: letter.content,
});
export function CoverLetterEditor({
letter,
disabled = false,
onSave,
onClose,
onDirtyChange,
actions,
}: CoverLetterEditorProps) {
const nameId = useId();
const [saved, setSaved] = useState(letter);
const [draft, setDraft] = useState(() => editableFields(letter));
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const saving = useRef(false);
const dirty = draft.name !== saved.name || draft.recipient !== saved.recipient || draft.content !== saved.content;
useEffect(() => {
onDirtyChange?.(dirty, pending);
}, [dirty, pending, onDirtyChange]);
useEffect(() => {
// Refresh saved styling only when no local edit would be overwritten.
if (!dirty && !pending && letter.revision > saved.revision) {
setSaved(letter);
setDraft(editableFields(letter));
}
}, [letter, saved.revision, dirty, pending]);
const save = async () => {
if (saving.current || disabled || !dirty) return;
if (!draft.name.trim()) {
setError(t`Enter a name for this cover letter.`);
return;
}
saving.current = true;
setPending(true);
setError("");
try {
const updated = await onSave({ ...draft, name: draft.name.trim(), expectedRevision: saved.revision });
setSaved(updated);
setDraft(editableFields(updated));
} catch (cause) {
setError(
cause instanceof ORPCError && cause.code === "CONFLICT"
? t`This cover letter changed elsewhere. Your edits are still here. Reload the latest version before saving again.`
: getReadableErrorMessage(cause, t`Could not save the cover letter. Please try again.`),
);
} finally {
saving.current = false;
setPending(false);
}
};
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<fieldset disabled={pending || disabled} className="min-w-0 space-y-4">
<div className="space-y-2">
<Label htmlFor={nameId}>
<Trans>Name</Trans>
</Label>
<Input
id={nameId}
value={draft.name}
maxLength={100}
required
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
/>
</div>
<fieldset className="min-w-0 space-y-2">
<legend className="font-medium text-sm">
<Trans>Recipient</Trans>
</legend>
<RichInput
aria-label={t`Recipient`}
value={draft.recipient}
editable={!pending && !disabled}
onChange={(recipient) => setDraft((current) => ({ ...current, recipient }))}
/>
</fieldset>
<fieldset className="min-w-0 space-y-2">
<legend className="font-medium text-sm">
<Trans>Content</Trans>
</legend>
<RichInput
aria-label={t`Content`}
value={draft.content}
editable={!pending && !disabled}
onChange={(content) => setDraft((current) => ({ ...current, content }))}
/>
</fieldset>
</fieldset>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" disabled={pending || disabled} onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="submit" disabled={!dirty || pending || disabled}>
<Trans>Save Changes</Trans>
</Button>
</div>
{dirty && (
<p className="text-muted-foreground text-sm">
<Trans>Save changes before previewing, exporting, or changing styling.</Trans>
</p>
)}
{actions?.(saved, dirty || pending || disabled)}
</form>
);
}
@@ -0,0 +1,327 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { CoverLetterEditorDialog } from "./editor-dialog";
type CoverLetterLibraryProps = {
initialResumeId?: string;
resumeReady?: boolean;
onEditingChange?: (editing: boolean) => void;
};
export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEditingChange }: CoverLetterLibraryProps) {
const queryClient = useQueryClient();
const nameId = useId();
const resumeInputId = useId();
const embeddedId = useId();
const importInput = useRef<HTMLInputElement>(null);
const running = useRef(false);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState("");
const [querySearch, setQuerySearch] = useState("");
const [offset, setOffset] = useState(0);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [resumeId, setResumeId] = useState<string | null>(initialResumeId ?? null);
const [embeddedKey, setEmbeddedKey] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => setQuerySearch(search), 250);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
onEditingChange?.(selectedId !== null);
}, [selectedId, onEditingChange]);
const query = useQuery(orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }));
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const source = useQuery(
orpc.resume.getById.queryOptions({ input: { id: resumeId ?? "" }, enabled: creating && !!resumeId }),
);
const embedded = (source.data?.data.customSections ?? []).flatMap((section) =>
section.type === "cover-letter"
? section.items.map((item, index) => ({
value: `${section.id}/${item.id}`,
label: `${section.title || t`Cover letter`} ${index + 1}`,
sectionId: section.id,
itemId: item.id,
}))
: [],
);
const selectedEmbedded = embedded.find((item) => item.value === embeddedKey);
const sourceReady = resumeId !== initialResumeId || resumeReady;
const run = async (action: () => Promise<void>) => {
if (running.current) return;
running.current = true;
setBusy(true);
try {
await action();
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not complete this action. Please try again.`),
});
} finally {
running.current = false;
setBusy(false);
}
};
const created = async (id: string) => {
await queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
setCreating(false);
setName("");
setSelectedId(id);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Input
className="min-w-48 flex-1"
aria-label={t`Search cover letters`}
placeholder={t`Search cover letters...`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setOffset(0);
}}
/>
<Button disabled={busy} onClick={() => setCreating(!creating)}>
<Trans>Create</Trans>
</Button>
<Button variant="outline" disabled={busy} onClick={() => importInput.current?.click()}>
<Trans>Import JSON</Trans>
</Button>
<input
ref={importInput}
type="file"
accept="application/json,.json"
aria-label={t`Import cover letter JSON`}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
void run(async () => {
const document = coverLetterDocumentSchema.parse(JSON.parse(await file.text()));
const letter = await orpc.coverLetters.import.call({ document });
await created(letter.id);
});
}}
/>
</div>
{creating && (
<div className="space-y-4 rounded-lg border p-4">
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!name.trim() || !sourceReady) return;
void run(async () => {
const letter = await orpc.coverLetters.create.call({
name: name.trim(),
...(resumeId ? { resumeId } : {}),
});
await created(letter.id);
});
}}
>
<fieldset disabled={busy} className="min-w-0 space-y-3">
<div className="space-y-2">
<Label htmlFor={nameId}>
<Trans>Name</Trans>
</Label>
<Input
id={nameId}
value={name}
maxLength={100}
required
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={resumeInputId}>
<Trans>Resume styling</Trans>
</Label>
<Combobox
id={resumeInputId}
disabled={busy}
className="w-full"
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={(id) => {
setResumeId(id);
setEmbeddedKey(null);
}}
showClear
placeholder={t`Default styling`}
/>
</div>
<Button type="submit" disabled={busy || !name.trim() || !sourceReady}>
<Trans>Create cover letter</Trans>
</Button>
</fieldset>
</form>
{resumeId && (
<div className="space-y-2">
<Label htmlFor={embeddedId}>
<Trans>Copy an existing letter from this resume</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
id={embeddedId}
className="min-w-48 flex-1"
disabled={busy || !sourceReady}
options={embedded}
value={embeddedKey}
onValueChange={setEmbeddedKey}
placeholder={t`Choose a cover letter`}
emptyMessage={t`This resume has no embedded cover letters.`}
/>
<Button
variant="outline"
disabled={busy || !sourceReady || !selectedEmbedded}
onClick={() =>
void run(async () => {
if (!selectedEmbedded) return;
const letter = await orpc.coverLetters.copyEmbedded.call({
resumeId,
sectionId: selectedEmbedded.sectionId,
itemId: selectedEmbedded.itemId,
...(name.trim() ? { name: name.trim() } : {}),
});
await created(letter.id);
})
}
>
<Trans>Save copy to library</Trans>
</Button>
</div>
<p className="text-muted-foreground text-xs">
<Trans>The original letter stays in your resume. Each copy can be edited independently.</Trans>
</p>
{!sourceReady && (
<p role="status" className="text-muted-foreground text-sm">
<Trans>Save resume changes before copying its content or styling.</Trans>
</p>
)}
{source.error && (
<p role="alert">{getReadableErrorMessage(source.error, t`Could not load the selected resume.`)}</p>
)}
</div>
)}
</div>
)}
{query.isPending ? (
<Spinner />
) : query.error ? (
<div className="space-y-2">
<p role="alert">{getReadableErrorMessage(query.error, t`Could not load cover letters.`)}</p>
<Button variant="outline" onClick={() => void query.refetch()}>
<Trans>Retry</Trans>
</Button>
</div>
) : query.data.items.length === 0 ? (
<p className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Trans>No cover letters found. Create one or import an existing letter.</Trans>
</p>
) : (
<ul className="divide-y rounded-lg border">
{query.data.items.map((letter) => (
<li key={letter.id}>
<button
type="button"
className="flex w-full items-center justify-between gap-4 p-4 text-start hover:bg-muted/50"
onClick={() => setSelectedId(letter.id)}
aria-label={t`Edit ${letter.name}`}
>
<span className="min-w-0 truncate font-medium">{letter.name}</span>
<time className="shrink-0 text-muted-foreground text-xs" dateTime={letter.updatedAt.toISOString()}>
{letter.updatedAt.toLocaleDateString()}
</time>
</button>
</li>
))}
</ul>
)}
{(offset > 0 || (query.data?.total ?? 0) > 20) && (
<div className="flex items-center justify-between gap-3">
<Button
variant="outline"
disabled={offset === 0 || query.isFetching}
onClick={() => setOffset(Math.max(0, offset - 20))}
>
<Trans>Previous</Trans>
</Button>
<span className="text-muted-foreground text-sm">
<Trans>Page {Math.floor(offset / 20) + 1}</Trans>
</span>
<Button
variant="outline"
disabled={offset + 20 >= (query.data?.total ?? 0) || query.isFetching}
onClick={() => setOffset(offset + 20)}
>
<Trans>Next</Trans>
</Button>
</div>
)}
{selectedId && (
<CoverLetterEditorDialog
key={selectedId}
letterId={selectedId}
activeResumeId={initialResumeId}
resumeReady={resumeReady}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}
type CoverLetterLibraryDialogProps = Pick<CoverLetterLibraryProps, "initialResumeId" | "resumeReady">;
export function CoverLetterLibraryDialog(props: CoverLetterLibraryDialogProps) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
return (
<>
<Button variant="outline" className="w-full" onClick={() => setOpen(true)}>
<Trans>Cover-letter library</Trans>
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!editing) setOpen(next);
}}
>
<DialogContent className="lg:max-w-3xl xl:max-w-4xl">
<DialogHeader>
<DialogTitle>
<Trans>Cover letters</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create and edit letters using your resume’s styling.</Trans>
</DialogDescription>
</DialogHeader>
{open && <CoverLetterLibrary {...props} onEditingChange={setEditing} />}
</DialogContent>
</Dialog>
</>
);
}
@@ -15,6 +15,7 @@ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.
type PdfViewerProps = {
className?: string;
data: ResumeData;
includeCoverLetterHeader?: boolean;
publicResume?: {
username: string;
slug: string;
@@ -72,7 +73,7 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
}
}
export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
export function PdfViewer({ className, data, publicResume, includeCoverLetterHeader }: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
@@ -88,8 +89,12 @@ export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
fileRef.current = null;
dispatch({ type: "resetForData" });
const createPdf = () =>
publicResume ? resolvePublicResumePdfBlob({ data, publicResume }) : createResumePdfBlob(data);
const createPdf = () => {
if (publicResume) return resolvePublicResumePdfBlob({ data, publicResume });
return includeCoverLetterHeader
? createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true })
: createResumePdfBlob(data);
};
void createPdf()
.then((blob) => {
@@ -108,7 +113,7 @@ export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
return () => {
isCancelled = true;
};
}, [data, publicResume]);
}, [data, publicResume, includeCoverLetterHeader]);
useEffect(() => {
void fileVersion;
+21
View File
@@ -31,6 +31,7 @@ import { Route as AuthVerify2faRouteImport } from "./routes/auth/verify-2fa";
import { Route as AuthVerify2faBackupRouteImport } from "./routes/auth/verify-2fa-backup";
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index";
import { Route as DashboardCoverLettersRouteImport } from "./routes/dashboard/cover-letters";
import { Route as TemplatesSplatRouteImport } from "./routes/templates/$";
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
@@ -152,6 +153,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
path: "/",
getParentRoute: () => DashboardRouteRoute,
} as any);
const DashboardCoverLettersRoute = DashboardCoverLettersRouteImport.update({
id: "/cover-letters",
path: "/cover-letters",
getParentRoute: () => DashboardRouteRoute,
} as any);
const TemplatesSplatRoute = TemplatesSplatRouteImport.update({
id: "/templates/$",
path: "/templates/$",
@@ -235,6 +241,7 @@ export interface FileRoutesByFullPath {
"/auth/resume-password": typeof AuthResumePasswordRoute;
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/agent/": typeof AgentIndexRoute;
"/auth/": typeof AuthIndexRoute;
@@ -264,6 +271,7 @@ export interface FileRoutesByTo {
"/auth/resume-password": typeof AuthResumePasswordRoute;
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/": typeof HomeIndexRoute;
"/agent": typeof AgentIndexRoute;
@@ -300,6 +308,7 @@ export interface FileRoutesById {
"/auth/resume-password": typeof AuthResumePasswordRoute;
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/_home/": typeof HomeIndexRoute;
"/agent/": typeof AgentIndexRoute;
@@ -337,6 +346,7 @@ export interface FileRouteTypes {
| "/auth/resume-password"
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/templates/$"
| "/agent/"
| "/auth/"
@@ -366,6 +376,7 @@ export interface FileRouteTypes {
| "/auth/resume-password"
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/templates/$"
| "/"
| "/agent"
@@ -401,6 +412,7 @@ export interface FileRouteTypes {
| "/auth/resume-password"
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/templates/$"
| "/_home/"
| "/agent/"
@@ -584,6 +596,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardIndexRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/dashboard/cover-letters": {
id: "/dashboard/cover-letters";
path: "/cover-letters";
fullPath: "/dashboard/cover-letters";
preLoaderRoute: typeof DashboardCoverLettersRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/templates/$": {
id: "/templates/$";
path: "/templates/$";
@@ -725,6 +744,7 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
);
interface DashboardRouteRouteChildren {
DashboardCoverLettersRoute: typeof DashboardCoverLettersRoute;
DashboardIndexRoute: typeof DashboardIndexRoute;
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
DashboardSettingsAccountRoute: typeof DashboardSettingsAccountRoute;
@@ -738,6 +758,7 @@ interface DashboardRouteRouteChildren {
}
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
DashboardCoverLettersRoute: DashboardCoverLettersRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardSettingsIntegrationsRouteRoute:
DashboardSettingsIntegrationsRouteRoute,
@@ -12,7 +12,8 @@ import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { getInitials } from "@reactive-resume/utils/string";
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
import { CoverLetterLibraryDialog } from "@/features/cover-letters/library";
import { useCurrentResume, useIsResumeLocked, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -60,6 +61,8 @@ function getSectionComponent(type: LeftSidebarSection) {
export function BuilderSidebarLeft() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const isLocked = useIsResumeLocked();
const resume = useCurrentResume();
const resumeReady = useResumeStore((state) => state.saveStatus !== "saving" && state.saveStatus !== "error");
return (
<>
@@ -68,6 +71,7 @@ export function BuilderSidebarLeft() {
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
<div className="space-y-4 p-4">
{isLocked && <LockBanner />}
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
{leftSidebarSections.map((section) => (
@@ -6,6 +6,7 @@ import {
BrainIcon,
BriefcaseIcon,
ChatCircleDotsIcon,
EnvelopeSimpleIcon,
GearSixIcon,
KeyIcon,
MagnifyingGlassIcon,
@@ -57,6 +58,11 @@ const appSidebarItems = [
label: msg`Applications`,
href: "/dashboard/applications",
},
{
icon: <EnvelopeSimpleIcon />,
label: msg`Cover letters`,
href: "/dashboard/cover-letters",
},
{
icon: <ChatCircleDotsIcon />,
label: msg`Agents`,
@@ -0,0 +1,18 @@
import { t } from "@lingui/core/macro";
import { EnvelopeSimpleIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { CoverLetterLibrary } from "@/features/cover-letters/library";
import { DashboardHeader } from "./-components/header";
export const Route = createFileRoute("/dashboard/cover-letters")({ component: RouteComponent });
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={EnvelopeSimpleIcon} title={t`Cover letters`} />
<Separator />
<CoverLetterLibrary />
</div>
);
}
+5712 -1
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
# Cover Letter Library Implementation Plan
> **For agentic workers:** Use executing-plans to implement this plan task by task. Backend ownership belongs to audit_builder; apps/web ownership belongs to root. Leave changes uncommitted for root review.
**Goal:** Persist cover letters independently, edit the same document from the library and builder, and export immutable application attachments.
**Architecture:** A user-owned cover_letter row stores rich text plus copied resume styling and sender information. Source resume/application links supply context, never synchronized content. Existing embedded letters remain independent and support explicit copying into the library.
**Tech Stack:** TypeScript, Zod, Drizzle/PostgreSQL, oRPC, React PDF, TanStack Query.
**Spec:** The contract and lifecycle below implement approved issue #3255 hybrid behavior.
## Global Constraints
- Base: origin/main e549d114ea020380b156197f6460faddbe3022fd; branch codex/issue-3255-cover-letter-library.
- No commits, pushes, merges or application attachment implementation in this worker.
- All shell commands use rtk. Source package boundaries follow AGENTS.md.
- Styling is copied at creation and on explicit refresh. Source deletion sets provenance IDs to null; saved appearance and content survive.
- Resume embedded cover-letter sections and their existing JSON imports remain untouched.
- Rich HTML must not introduce executable markup. Generated plain text is HTML-escaped before paragraph composition.
## Shared contract
```ts
type CoverLetterStyle = {
basics: ResumeData["basics"];
picture: ResumeData["picture"];
metadata: Omit<ResumeData["metadata"], "notes" | "layout">;
sectionId: string;
itemId: string;
};
type CoverLetter = {
id: string; name: string; recipient: string; content: string;
style: CoverLetterStyle;
sourceResumeId: string | null; sourceApplicationId: string | null;
revision: number; createdAt: Date; updatedAt: Date;
};
type CoverLetterDocument = {
format: "reactive-resume-cover-letter"; version: 1;
name: string; recipient: string; content: string; style: CoverLetterStyle;
};
```
Exported types/schema: `@reactive-resume/schema/cover-letter/data`.
Pure helpers: `@reactive-resume/resume/cover-letter` exposes `createCoverLetterResumeData(letter: Pick<CoverLetter, "name" | "recipient" | "content" | "style">): ResumeData`, `copyCoverLetterStyle(data: ResumeData, sectionId?: string, itemId?: string): CoverLetterStyle`, and `coverLetterTextToHtml(text: string): string`.
oRPC namespace `coverLetters`:
| Procedure | Input | Output |
| --- | --- | --- |
| list | `{ search?, resumeId?, applicationId?, limit?: number, offset?: number }` | `{ items: CoverLetter[], total: number }` |
| getById | `{ id }` | `CoverLetter` |
| create | `{ name, recipient?, content?, resumeId?, applicationId? }` | `CoverLetter` |
| update | `{ id, expectedRevision, name?, recipient?, content? }` | `CoverLetter` |
| refreshStyle | `{ id, expectedRevision, resumeId }` | `CoverLetter` |
| duplicate | `{ id, name? }` | `CoverLetter` |
| delete | `{ id, expectedRevision }` | `void` |
| copyEmbedded | `{ resumeId, sectionId, itemId, name? }` | `CoverLetter` |
| export | `{ id }` | `CoverLetterDocument` |
| import | `{ document: CoverLetterDocument }` | `CoverLetter` |
List defaults limit=20, offset=0; limit max100; literal case-insensitive name search. Stable ordering updatedAt descending, id descending. IDs and dates are server-owned. Every read/write enforces user ownership; inaccessible contexts return NOT_FOUND. Atomic revision predicates return CONFLICT for stale same-user mutations, NOT_FOUND for absent/foreign documents. Import always creates a new owned ID and clears provenance.
AI `applications.ai.draftMessage` retains `{ text }` and adds optional `coverLetterId`; cover-letter generation persists before returning. Follow-up generation stays transient. Client opens saved document by ID rather than making another copy.
## Lifecycle and UI integration
Library and builder select the same row. Editor uses RichInput and explicit Save with revision; CONFLICT offers reload without discarding current edits automatically. Name/content views render text or existing trusted rich-editor/PDF pathways, never raw HTML insertion. Empty document creation uses default resume styling; chosen resume copies basics/picture and metadata except private notes and resume layout. Composer includes only one full-width cover-letter section with empty standard sections and notes.
Refresh explicitly replaces copied styling/sender only, preserves letter text/name and identity. Embedded copy preserves original section/item IDs so existing targeted style rules continue to apply. Original embedded letter remains unchanged and independently editable. Account export includes coverLetters; standalone versioned JSON has content and styling needed to render without original context IDs. Images remain URL references, not embedded bytes. Resume deletion removes screenshots/PDFs only and keeps picture assets; deleting the entire account removes its owned assets and documents.
PDF uses `createResumePdfBlob(createCoverLetterResumeData(letter))`. Application action uploads the resulting File via existing `applications.attachDocument({id,kind:"cover-letter",file})`. Later edits/deletion of source letters never mutate uploaded attachment snapshots. PR #3395's escaping and composition move into pure domain helper; its attachment endpoint is reused.
## Task 1: Schema and pure composition
Files: packages/schema/src/cover-letter/data.ts; packages/resume/src/cover-letter.ts; their tests and package export maps.
- [ ] Add failing tests for snapshot isolation and preserved sender/target IDs; escaped `<script>` plain text; absence of resume sections/notes in composed data; round-trip document shape.
- [ ] Run `rtk proxy pnpm --filter @reactive-resume/resume exec vitest run src/cover-letter.test.ts` and capture RED.
- [ ] Implement exported contract and pure helpers; verify `expect(result.basics).toEqual(source.basics)` and `expect(result.metadata.notes).toBe("")`.
- [ ] Run schema/domain tests and typechecks to GREEN.
## Task 2: Owned persistence and contracts
Files: packages/db/src/schema/cover-letter.ts, schema/index.ts, generated migrations; packages/api/src/dto/cover-letter.ts; features/cover-letters/{service,router,html}.ts; routers/index.ts.
- [ ] Add failing service/procedure tests for account isolation, literal search and paging, stale update/delete/refresh conflicts, duplication, refreshed style preserving content, foreign context rejection and JSON import clearing context.
- [ ] Run `rtk proxy pnpm --filter @reactive-resume/api exec vitest run src/features/cover-letters` and capture RED.
- [ ] Implement explicit user predicates on all queries and atomic `{userId,id,revision}` update/delete conditions with revision increment.
- [ ] Add allowlisted rich-HTML sanitization at every persistence boundary, covering script/event/style/unsafe URL removal and rich-text formatting retention.
- [ ] Generate additive table migration with cascade owner and SET NULL source FKs; verify disposable DB preserves snapshots after source deletion.
- [ ] Run API tests and DB/schema/API typechecks; distinguish known email baseline errors.
## Task 3: Generation and backup integration
Files: packages/api/src/features/applications/ai.ts and tests; packages/api/src/features/auth/service.ts and tests.
- [ ] Test generation stores escaped content before response, persistence failure rejects operation, follow-up does not create a letter, and owner backup includes independent documents.
- [ ] Persist generated letter with linked application/resume context and return `coverLetterId` beside original text.
- [ ] Include owned cover-letter data in account backup without introducing restore assumptions for resume imports.
- [ ] Run focused and package tests; review root UI integration against fixed contract.
## Task 4: Verification and handoff
- [ ] Run scoped Biome, package typechecks, boundary check, generated migration no-changes check and meaningful regression suites.
- [ ] Send root exact changed files, RED/GREEN evidence, migration order and any unresolved limitations. Root performs final review and publishing.
+1
View File
@@ -57,6 +57,7 @@
"react-email",
"react-pdf-html",
"resumable-stream",
"sanitize-html",
"sharp",
"tokenx",
"ts-pattern",
@@ -0,0 +1,18 @@
CREATE TABLE "cover_letter" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"recipient" text DEFAULT '' NOT NULL,
"content" text DEFAULT '' NOT NULL,
"style" jsonb NOT NULL,
"source_resume_id" text,
"source_application_id" text,
"revision" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "cover_letter_user_id_updated_at_id_index" ON "cover_letter" ("user_id","updated_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint
ALTER TABLE "cover_letter" ADD CONSTRAINT "cover_letter_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD CONSTRAINT "cover_letter_source_resume_id_resume_id_fkey" FOREIGN KEY ("source_resume_id") REFERENCES "resume"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD CONSTRAINT "cover_letter_source_application_id_application_id_fkey" FOREIGN KEY ("source_application_id") REFERENCES "application"("id") ON DELETE SET NULL;
File diff suppressed because it is too large Load Diff
+4
View File
@@ -57,6 +57,7 @@
"ollama-ai-provider-v2": "^4.0.1",
"react": "^19.2.8",
"resumable-stream": "^2.2.12",
"sanitize-html": "^2.17.7",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
@@ -65,7 +66,10 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/pg": "^8.23.1",
"@types/sanitize-html": "^2.16.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"pg": "^8.23.0",
"typescript": "^7.0.2"
}
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { coverLetterDto } from "./cover-letter";
describe("cover-letter context validation", () => {
it.each(["resumeId", "applicationId"])("rejects empty %s before it reaches foreign-key persistence", (field) => {
expect(coverLetterDto.create.input.safeParse({ name: "Letter", [field]: "" }).success).toBe(false);
expect(coverLetterDto.list.input.safeParse({ [field]: "" }).success).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import z from "zod";
import {
coverLetterContentSchema,
coverLetterDocumentSchema,
coverLetterSchema,
} from "@reactive-resume/schema/cover-letter/data";
const idSchema = z.object({ id: z.string().min(1) });
const revisionSchema = idSchema.extend({ expectedRevision: z.number().int().min(1) });
const editableSchema = coverLetterContentSchema.pick({ name: true, recipient: true, content: true });
export const coverLetterDto = {
list: {
input: z
.object({
search: z.string().max(100).optional(),
resumeId: z.string().min(1).optional(),
applicationId: z.string().min(1).optional(),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).default(0),
})
.default({ limit: 20, offset: 0 }),
output: z.object({ items: z.array(coverLetterSchema), total: z.number() }),
},
getById: { input: idSchema, output: coverLetterSchema },
create: {
input: editableSchema.extend({
recipient: editableSchema.shape.recipient.default(""),
content: editableSchema.shape.content.default(""),
resumeId: z.string().min(1).optional(),
applicationId: z.string().min(1).optional(),
}),
output: coverLetterSchema,
},
update: { input: revisionSchema.extend(editableSchema.partial().shape), output: coverLetterSchema },
refreshStyle: { input: revisionSchema.extend({ resumeId: z.string().min(1) }), output: coverLetterSchema },
duplicate: { input: idSchema.extend({ name: editableSchema.shape.name.optional() }), output: coverLetterSchema },
delete: { input: revisionSchema, output: z.void() },
copyEmbedded: {
input: z.object({
resumeId: z.string().min(1),
sectionId: z.string().min(1),
itemId: z.string().min(1),
name: editableSchema.shape.name.optional(),
}),
output: coverLetterSchema,
},
export: { input: idSchema, output: coverLetterDocumentSchema },
import: { input: z.object({ document: coverLetterDocumentSchema }), output: coverLetterSchema },
};
export type CoverLetterListInput = z.infer<typeof coverLetterDto.list.input>;
export type CoverLetterUpdateInput = z.infer<typeof coverLetterDto.update.input>;
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRouterClient } from "@orpc/server";
const mocks = vi.hoisted(() => ({ create: vi.fn(), generate: vi.fn(), application: vi.fn(), resume: vi.fn() }));
vi.mock("../../context", async () => {
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
return { protectedProcedure: os.$context<{ user: { id: string }; locale: "en-US" }>() };
});
vi.mock("../../middleware/rate-limit", async () => {
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
return { aiRequestRateLimit: os.middleware(({ next }) => next()) };
});
vi.mock("ai", () => ({ generateText: mocks.generate }));
vi.mock("../ai/service", () => ({ getModel: vi.fn(() => ({})) }));
vi.mock("../ai-providers/service", () => ({
aiProvidersService: {
getDefaultRunnable: vi.fn(async () => ({ provider: "openai", model: "test", apiKey: "test" })),
},
}));
vi.mock("../resume/service", () => ({ resumeService: { getById: mocks.resume } }));
vi.mock("../cover-letters/service", () => ({ coverLetterService: { create: mocks.create } }));
vi.mock("./service", () => ({ applicationService: { getById: mocks.application } }));
const { aiRouter } = await import("./ai");
const client = createRouterClient(aiRouter, { context: { user: { id: "owner" }, locale: "en-US" } as never });
describe("persistent generated cover letters", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.application.mockResolvedValue({ id: "application", company: "Acme", role: "Engineer", resumeId: null });
mocks.generate.mockResolvedValue({ text: "Hello <team>" });
mocks.create.mockResolvedValue({ id: "saved-letter" });
});
it("saves escaped cover letter before returning its identity", async () => {
expect(await client.draftMessage({ id: "application", kind: "cover-letter" })).toEqual({
text: "Hello <team>",
coverLetterId: "saved-letter",
});
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: "owner", applicationId: "application", content: "<p>Hello &lt;team&gt;</p>" }),
);
});
it("does not report a saved letter when persistence fails", async () => {
mocks.create.mockRejectedValueOnce(new Error("DB unavailable"));
await expect(client.draftMessage({ id: "application", kind: "cover-letter" })).rejects.toThrow();
});
it("keeps follow-up messages transient", async () => {
expect(await client.draftMessage({ id: "application", kind: "follow-up" })).toEqual({ text: "Hello <team>" });
expect(mocks.create).not.toHaveBeenCalled();
});
});
@@ -23,6 +23,7 @@ vi.mock("../../middleware/rate-limit", () => ({ aiRequestRateLimit: vi.fn() }));
vi.mock("../ai/service", () => ({ getModel: vi.fn() }));
vi.mock("../ai-providers/service", () => ({ aiProvidersService: { getDefaultRunnable: vi.fn() } }));
vi.mock("../resume/service", () => ({ resumeService: { getById: vi.fn(), create: vi.fn() } }));
vi.mock("../cover-letters/service", () => ({ coverLetterService: { create: vi.fn() } }));
vi.mock("./service", () => ({
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
}));
+13 -2
View File
@@ -1,12 +1,14 @@
import { ORPCError } from "@orpc/client";
import { APICallError, generateText, RetryError } from "ai";
import z from "zod";
import { coverLetterTextToHtml } from "@reactive-resume/resume/cover-letter";
import { generateId, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { generateJson as sharedGenerateJson } from "../ai/generate-json";
import { getModel } from "../ai/service";
import { aiProvidersService } from "../ai-providers/service";
import { coverLetterService } from "../cover-letters/service";
import { resumeService } from "../resume/service";
import { applicationService } from "./service";
@@ -184,7 +186,7 @@ export const aiRouter = {
})
.input(z.object({ id: z.string(), kind: z.enum(["cover-letter", "follow-up"]) }))
.use(aiRequestRateLimit)
.output(z.object({ text: z.string() }))
.output(z.object({ text: z.string(), coverLetterId: z.string().optional() }))
.errors(aiErrors)
.handler(async ({ context, input }) => {
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
@@ -200,7 +202,16 @@ export const aiRouter = {
? `Write a concise, specific cover letter (250-350 words, no placeholders like [Name]) for this application, drawing on the resume. Return only the letter text.\n\n${context_}`
: `Write a short, polite follow-up message (80-120 words) to a recruiter checking in on this application. Warm but not pushy. Return only the message text.\n\n${context_}`;
return { text: await generatePlainText(model, prompt) };
const text = await generatePlainText(model, prompt);
if (input.kind === "follow-up") return { text };
const letter = await coverLetterService.create({
userId: context.user.id,
name: `${application.company} — ${application.role}`.slice(0, 100),
content: coverLetterTextToHtml(text),
applicationId: input.id,
...(resume ? { resumeId: resume.id } : {}),
});
return { text, coverLetterId: letter.id };
}),
// Create a tailored copy of the linked resume (job-specific summary) and link it to the application.
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const mocks = vi.hoisted(() => ({ select: vi.fn(), predicates: [] as unknown[] }));
vi.mock("@reactive-resume/db/client", () => ({ db: { select: mocks.select } }));
vi.mock("@reactive-resume/db/schema", () => ({
user: { id: "user.id" },
resume: { userId: "resume.userId" },
coverLetter: { userId: "coverLetter.userId" },
}));
vi.mock("drizzle-orm", () => ({ eq: (column: unknown, value: unknown) => ({ column, value }) }));
vi.mock("@reactive-resume/env/server", () => ({ env: {} }));
vi.mock("../storage/service", () => ({ getStorageService: vi.fn() }));
const { authService } = await import("./service");
describe("account backup", () => {
it("includes owned independent cover letters alongside embedded resume letters", async () => {
const letter = {
id: "letter",
name: "Saved",
recipient: "",
content: "<p>Body</p>",
style: copyCoverLetterStyle(defaultResumeData),
sourceResumeId: null,
sourceApplicationId: null,
revision: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
for (const rows of [[{ id: "owner", name: "Owner" }], [{ id: "resume", data: defaultResumeData }], [letter]]) {
mocks.select.mockReturnValueOnce({
from: () => ({
where: (predicate: unknown) => {
mocks.predicates.push(predicate);
return Promise.resolve(rows);
},
}),
});
}
const exported = await authService.exportData({ userId: "owner" });
expect(exported).toMatchObject({ coverLetters: [letter], resumes: [{ id: "resume" }] });
expect(mocks.predicates).toContainEqual({ column: "coverLetter.userId", value: "owner" });
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ export const authRouter = {
operationId: "exportAccountData",
summary: "Export user account data",
description:
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields and all of their resumes. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields, resumes, and independent cover letters. Images remain URL references. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
successDescription: "The user's exported account data.",
})
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
+8 -1
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
import { getStorageService } from "../storage/service";
export type ProviderList = Partial<Record<AuthProvider, string>>;
@@ -59,7 +60,13 @@ export const authService = {
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
return { exportedAt: new Date().toISOString(), user: userRecord, resumes };
const coverLetters = await db.select().from(schema.coverLetter).where(eq(schema.coverLetter.userId, input.userId));
return {
exportedAt: new Date().toISOString(),
user: userRecord,
resumes,
coverLetters: coverLetters.map((letter) => coverLetterSchema.parse(letter)),
};
},
deleteAccount: async (input: { userId: string }): Promise<void> => {
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { sanitizeCoverLetterHtml } from "./html";
describe("saved cover-letter HTML", () => {
it("removes executable markup, event handlers and unsafe links", () => {
const result = sanitizeCoverLetterHtml(
'<script>alert(1)</script><p onclick="alert(2)">Hello <a href="javascript:alert(3)">team</a><img src=x onerror="alert(4)"></p>',
);
expect(result).toBe("<p>Hello <a>team</a></p>");
});
it("preserves rich editor formatting and safe links while removing CSS URL payloads", () => {
const result = sanitizeCoverLetterHtml(
'<p style="text-align:center;background-image:url(https://evil.test);color:#aabbcc"><strong>Dear</strong> <em>team</em><br><a href="https://example.com">Portfolio</a></p>',
);
expect(result).toContain('style="text-align:center;color:#aabbcc"');
expect(result).toContain("<strong>Dear</strong> <em>team</em><br />");
expect(result).toContain('href="https://example.com"');
expect(result).not.toContain("evil.test");
});
});
@@ -0,0 +1,42 @@
import sanitizeHtml from "sanitize-html";
// Match RichInput's formatting surface, without executable markup or CSS URLs.
export function sanitizeCoverLetterHtml(html: string): string {
return sanitizeHtml(html, {
allowedTags: [
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"ul",
"ol",
"li",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"span",
"mark",
"a",
],
allowedAttributes: { "*": ["style"], a: ["href", "title"], ol: ["start"] },
allowedSchemes: ["http", "https", "mailto", "tel"],
allowProtocolRelative: false,
allowedStyles: {
"*": {
"text-align": [/^(?:left|right|center|justify)$/],
color: [/^#[\da-f]{3,8}$/i, /^rgba?\([\d.,\s%]+\)$/i, /^[a-z]+$/i],
"background-color": [/^#[\da-f]{3,8}$/i, /^rgba?\([\d.,\s%]+\)$/i, /^[a-z]+$/i],
},
},
});
}
@@ -0,0 +1,46 @@
import { protectedProcedure } from "../../context";
import { coverLetterDto } from "../../dto/cover-letter";
import { coverLetterService } from "./service";
export const coverLettersRouter = {
list: protectedProcedure
.input(coverLetterDto.list.input)
.output(coverLetterDto.list.output)
.handler(({ context, input }) => coverLetterService.list({ ...input, userId: context.user.id })),
getById: protectedProcedure
.input(coverLetterDto.getById.input)
.output(coverLetterDto.getById.output)
.handler(({ context, input }) => coverLetterService.getById({ ...input, userId: context.user.id })),
create: protectedProcedure
.input(coverLetterDto.create.input)
.output(coverLetterDto.create.output)
.handler(({ context, input }) => coverLetterService.create({ ...input, userId: context.user.id })),
update: protectedProcedure
.input(coverLetterDto.update.input)
.output(coverLetterDto.update.output)
.handler(({ context, input }) => coverLetterService.update({ ...input, userId: context.user.id })),
refreshStyle: protectedProcedure
.input(coverLetterDto.refreshStyle.input)
.output(coverLetterDto.refreshStyle.output)
.handler(({ context, input }) => coverLetterService.refreshStyle({ ...input, userId: context.user.id })),
duplicate: protectedProcedure
.input(coverLetterDto.duplicate.input)
.output(coverLetterDto.duplicate.output)
.handler(({ context, input }) => coverLetterService.duplicate({ ...input, userId: context.user.id })),
delete: protectedProcedure
.input(coverLetterDto.delete.input)
.output(coverLetterDto.delete.output)
.handler(({ context, input }) => coverLetterService.delete({ ...input, userId: context.user.id })),
copyEmbedded: protectedProcedure
.input(coverLetterDto.copyEmbedded.input)
.output(coverLetterDto.copyEmbedded.output)
.handler(({ context, input }) => coverLetterService.copyEmbedded({ ...input, userId: context.user.id })),
export: protectedProcedure
.input(coverLetterDto.export.input)
.output(coverLetterDto.export.output)
.handler(({ context, input }) => coverLetterService.export({ ...input, userId: context.user.id })),
import: protectedProcedure
.input(coverLetterDto.import.input)
.output(coverLetterDto.import.output)
.handler(({ context, input }) => coverLetterService.import({ ...input, userId: context.user.id })),
};
@@ -0,0 +1,227 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ORPCError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const fixture = vi.hoisted(() => ({
db: undefined as ReturnType<typeof drizzle> | undefined,
pool: undefined as Pool | undefined,
}));
vi.mock("@reactive-resume/db/client", () => ({
get db() {
return fixture.db;
},
}));
vi.mock("../../context", async () => {
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
return { protectedProcedure: os.$context<{ user: { id: string } }>() };
});
vi.mock("../resume/service", () => ({
resumeService: {
getById: async ({ id, userId }: { id: string; userId: string }) => {
const result = await getPool().query("SELECT id, data FROM resume WHERE id=$1 AND user_id=$2", [id, userId]);
if (!result.rows[0]) throw new ORPCError("NOT_FOUND");
return result.rows[0];
},
},
}));
function getPool(): Pool {
if (!fixture.pool) throw new Error("Test database is not initialized");
return fixture.pool;
}
// Opt-in real PostgreSQL tests. Use a disposable database; all tables live in an isolated schema.
describe.skipIf(!process.env.COVER_LETTER_TEST_DATABASE_URL)("cover-letter owned persistence", () => {
let service: typeof import("./service").coverLetterService;
const schemaName = `cover_letter_test_${randomUUID().replaceAll("-", "")}`;
let admin: Pool;
beforeAll(async () => {
admin = new Pool({ connectionString: process.env.COVER_LETTER_TEST_DATABASE_URL });
await admin.query(`CREATE SCHEMA ${schemaName}`);
fixture.pool = new Pool({
connectionString: process.env.COVER_LETTER_TEST_DATABASE_URL,
options: `-c search_path=${schemaName}`,
});
fixture.db = drizzle({ client: fixture.pool });
await fixture.pool.query(
'CREATE TABLE "user" (id text PRIMARY KEY); CREATE TABLE resume (id text PRIMARY KEY, user_id text, data jsonb); CREATE TABLE application (id text PRIMARY KEY, user_id text);',
);
const migration = await readFile(
new URL("../../../../../migrations/20260905121445_cover_letter_library/migration.sql", import.meta.url),
"utf8",
);
await fixture.pool.query(migration.replaceAll('"public".', ""));
service = (await import("./service")).coverLetterService;
});
afterAll(async () => {
await fixture.pool?.end();
await admin?.query(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
await admin?.end();
});
beforeEach(async () => {
await getPool().query('TRUNCATE "user",resume,application,cover_letter CASCADE');
await getPool().query("INSERT INTO \"user\" VALUES ('alice'),('bob')");
await getPool().query("INSERT INTO resume VALUES ('alice-resume','alice',$1),('bob-resume','bob',$1)", [
defaultResumeData,
]);
await getPool().query("INSERT INTO application VALUES ('alice-app','alice'),('bob-app','bob')");
});
it("persists one shared document and atomically rejects stale writers and deletes", async () => {
const created = await service.create({ userId: "alice", name: "Draft", content: "<p>First</p>" });
const saved = await service.update({
userId: "alice",
id: created.id,
expectedRevision: 1,
content: "<p>Second</p>",
});
expect(saved.revision).toBe(2);
expect((await service.getById({ userId: "alice", id: created.id })).content).toBe("<p>Second</p>");
await expect(
service.update({ userId: "alice", id: created.id, expectedRevision: 1, name: "Stale" }),
).rejects.toMatchObject({ code: "CONFLICT" });
await expect(service.delete({ userId: "alice", id: created.id, expectedRevision: 1 })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("accepts procedure defaults, rejects invalid input, and permits only one concurrent writer", async () => {
const { coverLettersRouter } = await import("./router");
const client = createRouterClient(coverLettersRouter, { context: { user: { id: "alice" } } as never });
const created = await client.create({ name: "Through procedure" });
expect(created).toMatchObject({ recipient: "", content: "", revision: 1 });
await expect(client.create({ name: " " })).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(client.list({ limit: 101 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
const results = await Promise.allSettled([
client.update({ id: created.id, expectedRevision: 1, content: "First editor" }),
client.update({ id: created.id, expectedRevision: 1, content: "Second editor" }),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(results.find((result) => result.status === "rejected")).toMatchObject({ reason: { code: "CONFLICT" } });
});
it("copies an embedded letter without modifying the source and keeps targeted style identifiers", async () => {
const source = structuredClone(defaultResumeData);
source.customSections = [
{
id: "embedded",
title: "Embedded Letter",
type: "cover-letter",
icon: "",
columns: 1,
hidden: true,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "embedded-item", hidden: true, recipient: "Recipient", content: "<p>Original</p>" }],
},
];
await getPool().query("UPDATE resume SET data=$1 WHERE id='alice-resume'", [source]);
const copy = await service.copyEmbedded({
userId: "alice",
resumeId: "alice-resume",
sectionId: "embedded",
itemId: "embedded-item",
});
expect(copy).toMatchObject({
name: "Embedded Letter",
content: "<p>Original</p>",
style: { sectionId: "embedded", itemId: "embedded-item" },
});
await service.update({ userId: "alice", id: copy.id, expectedRevision: 1, content: "Changed independently" });
const result = await getPool().query("SELECT data FROM resume WHERE id='alice-resume'");
expect(result.rows[0].data).toEqual(source);
await expect(
service.copyEmbedded({ userId: "bob", resumeId: "alice-resume", sectionId: "embedded", itemId: "embedded-item" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("isolates every read, mutation, export and context selection by account", async () => {
const created = await service.create({ userId: "alice", name: "Private" });
const foreign = { userId: "bob", id: created.id, expectedRevision: 1 };
for (const operation of [
() => service.getById(foreign),
() => service.update({ ...foreign, name: "Stolen" }),
() => service.delete(foreign),
() => service.duplicate(foreign),
() => service.export(foreign),
() => service.refreshStyle({ ...foreign, resumeId: "bob-resume" }),
]) {
await expect(operation()).rejects.toMatchObject({ code: "NOT_FOUND" });
}
expect((await service.list({ userId: "bob", limit: 20, offset: 0 })).items).toEqual([]);
await expect(service.create({ userId: "alice", name: "Invalid", resumeId: "bob-resume" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(service.create({ userId: "alice", name: "Invalid", applicationId: "bob-app" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("refreshes copied style without changing content and survives source deletion", async () => {
const created = await service.create({
userId: "alice",
name: "Keep",
content: "<p>Keep body</p>",
resumeId: "alice-resume",
applicationId: "alice-app",
});
const changed = structuredClone(defaultResumeData);
changed.basics.name = "New sender";
await getPool().query("UPDATE resume SET data=$1 WHERE id='alice-resume'", [changed]);
expect((await service.getById({ userId: "alice", id: created.id })).style.basics.name).toBe("");
const refreshed = await service.refreshStyle({
userId: "alice",
id: created.id,
expectedRevision: 1,
resumeId: "alice-resume",
});
expect(refreshed).toMatchObject({
name: "Keep",
content: "<p>Keep body</p>",
revision: 2,
style: { basics: { name: "New sender" } },
});
await expect(
service.refreshStyle({ userId: "alice", id: created.id, expectedRevision: 1, resumeId: "alice-resume" }),
).rejects.toMatchObject({ code: "CONFLICT" });
await getPool().query("DELETE FROM resume WHERE id='alice-resume'; DELETE FROM application WHERE id='alice-app'");
expect(await service.getById({ userId: "alice", id: created.id })).toMatchObject({
sourceResumeId: null,
sourceApplicationId: null,
style: { basics: { name: "New sender" } },
});
});
it("searches literal names and paginates stable results within owner context", async () => {
await service.create({ userId: "alice", name: "100%_match", resumeId: "alice-resume" });
await service.create({ userId: "alice", name: "100 percent" });
expect((await service.list({ userId: "alice", search: "%_", limit: 20, offset: 0 })).total).toBe(1);
const page = await service.list({ userId: "alice", limit: 1, offset: 1 });
expect(page.total).toBe(2);
expect(page.items).toHaveLength(1);
expect((await service.list({ userId: "alice", resumeId: "alice-resume", limit: 20, offset: 0 })).total).toBe(1);
});
it("round-trips standalone exports without foreign provenance and sanitizes all writes", async () => {
const created = await service.create({
userId: "alice",
name: "Original",
resumeId: "alice-resume",
content: '<p onclick="evil()">Safe<script>evil()</script></p>',
});
expect(created.content).toBe("<p>Safe</p>");
const document = await service.export({ userId: "alice", id: created.id });
const imported = await service.import({ userId: "bob", document });
expect(imported.id).not.toBe(created.id);
expect(imported).toMatchObject({ sourceResumeId: null, sourceApplicationId: null, content: "<p>Safe</p>" });
const copy = await service.duplicate({ userId: "alice", id: created.id });
await service.delete({ userId: "alice", id: created.id, expectedRevision: 1 });
expect((await service.getById({ userId: "alice", id: copy.id })).content).toBe("<p>Safe</p>");
});
});
@@ -0,0 +1,195 @@
import type { CoverLetter, CoverLetterDocument, CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
import type { CoverLetterListInput, CoverLetterUpdateInput } from "../../dto/cover-letter";
import { ORPCError } from "@orpc/client";
import { and, count, desc, eq, ilike, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
import {
coverLetterContentSchema,
coverLetterDocumentSchema,
coverLetterSchema,
} from "@reactive-resume/schema/cover-letter/data";
import { coverLetterItemSchema, resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { resumeService } from "../resume/service";
import { sanitizeCoverLetterHtml } from "./html";
type OwnedId = { userId: string; id: string };
type RevisionInput = OwnedId & { expectedRevision: number };
type CreateInput = {
userId: string;
name: string;
recipient?: string | undefined;
content?: string | undefined;
resumeId?: string | undefined;
applicationId?: string | undefined;
};
async function getById(input: OwnedId): Promise<CoverLetter> {
const [row] = await db
.select()
.from(schema.coverLetter)
.where(and(eq(schema.coverLetter.id, input.id), eq(schema.coverLetter.userId, input.userId)));
if (!row) throw new ORPCError("NOT_FOUND");
return coverLetterSchema.parse(row);
}
async function getResumeStyle(userId: string, resumeId?: string, sectionId?: string, itemId?: string) {
const data = resumeId
? resumeDataSchema.parse((await resumeService.getById({ userId, id: resumeId })).data)
: defaultResumeData;
return copyCoverLetterStyle(data, sectionId, itemId);
}
async function assertOwnedApplication(userId: string, id?: string) {
if (!id) return;
const [application] = await db
.select({ id: schema.application.id })
.from(schema.application)
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)));
if (!application) throw new ORPCError("NOT_FOUND");
}
async function insert(input: {
userId: string;
name: string;
recipient: string;
content: string;
style: CoverLetterStyle;
sourceResumeId?: string | null;
sourceApplicationId?: string | null;
}): Promise<CoverLetter> {
const content = coverLetterContentSchema.parse(input);
const [row] = await db
.insert(schema.coverLetter)
.values({
...content,
userId: input.userId,
recipient: sanitizeCoverLetterHtml(content.recipient),
content: sanitizeCoverLetterHtml(content.content),
sourceResumeId: input.sourceResumeId ?? null,
sourceApplicationId: input.sourceApplicationId ?? null,
})
.returning();
return coverLetterSchema.parse(row);
}
async function updateRevision(
input: RevisionInput,
changes: Partial<typeof schema.coverLetter.$inferInsert>,
): Promise<CoverLetter> {
const [row] = await db
.update(schema.coverLetter)
.set({ ...changes, revision: sql`${schema.coverLetter.revision} + 1` })
.where(
and(
eq(schema.coverLetter.id, input.id),
eq(schema.coverLetter.userId, input.userId),
eq(schema.coverLetter.revision, input.expectedRevision),
),
)
.returning();
if (row) return coverLetterSchema.parse(row);
await getById(input);
throw new ORPCError("CONFLICT", { message: "This cover letter changed elsewhere. Reload it before saving again." });
}
export const coverLetterService = {
getById,
list: async (input: CoverLetterListInput & { userId: string }) => {
const filters = [eq(schema.coverLetter.userId, input.userId)];
if (input.resumeId) filters.push(eq(schema.coverLetter.sourceResumeId, input.resumeId));
if (input.applicationId) filters.push(eq(schema.coverLetter.sourceApplicationId, input.applicationId));
if (input.search?.trim())
filters.push(ilike(schema.coverLetter.name, `%${input.search.trim().replace(/[\\%_]/g, "\\$&")}%`));
const where = and(...filters);
const [rows, totals] = await Promise.all([
db
.select()
.from(schema.coverLetter)
.where(where)
.orderBy(desc(schema.coverLetter.updatedAt), desc(schema.coverLetter.id))
.limit(input.limit)
.offset(input.offset),
db.select({ total: count() }).from(schema.coverLetter).where(where),
]);
return { items: rows.map((row) => coverLetterSchema.parse(row)), total: totals[0]?.total ?? 0 };
},
create: async (input: CreateInput) => {
await assertOwnedApplication(input.userId, input.applicationId);
const style = await getResumeStyle(input.userId, input.resumeId);
return insert({
userId: input.userId,
name: input.name,
recipient: input.recipient ?? "",
content: input.content ?? "",
style,
sourceResumeId: input.resumeId ?? null,
sourceApplicationId: input.applicationId ?? null,
});
},
update: (input: CoverLetterUpdateInput & { userId: string }) => {
const changes: Partial<typeof schema.coverLetter.$inferInsert> = {};
if (input.name !== undefined) changes.name = coverLetterContentSchema.shape.name.parse(input.name);
if (input.recipient !== undefined)
changes.recipient = sanitizeCoverLetterHtml(coverLetterContentSchema.shape.recipient.parse(input.recipient));
if (input.content !== undefined)
changes.content = sanitizeCoverLetterHtml(coverLetterContentSchema.shape.content.parse(input.content));
return updateRevision(input, changes);
},
refreshStyle: async (input: RevisionInput & { resumeId: string }) => {
const letter = await getById(input);
const style = await getResumeStyle(input.userId, input.resumeId, letter.style.sectionId, letter.style.itemId);
return updateRevision(input, { style, sourceResumeId: input.resumeId });
},
duplicate: async (input: OwnedId & { name?: string | undefined }) => {
const letter = await getById(input);
return insert({ ...letter, userId: input.userId, name: input.name ?? `${letter.name} (copy)`.slice(0, 100) });
},
delete: async (input: RevisionInput): Promise<void> => {
const rows = await db
.delete(schema.coverLetter)
.where(
and(
eq(schema.coverLetter.id, input.id),
eq(schema.coverLetter.userId, input.userId),
eq(schema.coverLetter.revision, input.expectedRevision),
),
)
.returning({ id: schema.coverLetter.id });
if (rows.length) return;
await getById(input);
throw new ORPCError("CONFLICT", { message: "This cover letter changed elsewhere. Reload it before deleting." });
},
copyEmbedded: async (input: {
userId: string;
resumeId: string;
sectionId: string;
itemId: string;
name?: string | undefined;
}) => {
const resume = await resumeService.getById({ userId: input.userId, id: input.resumeId });
const data = resumeDataSchema.parse(resume.data);
const section = data.customSections.find((item) => item.id === input.sectionId && item.type === "cover-letter");
const item = section?.items.find((entry) => entry.id === input.itemId);
if (!item) throw new ORPCError("NOT_FOUND");
const letter = coverLetterItemSchema.parse(item);
return insert({
userId: input.userId,
name: input.name ?? (section?.title || "Cover Letter").slice(0, 100),
recipient: letter.recipient,
content: letter.content,
style: copyCoverLetterStyle(data, input.sectionId, input.itemId),
sourceResumeId: input.resumeId,
});
},
export: async (input: OwnedId): Promise<CoverLetterDocument> => {
const letter = await getById(input);
return coverLetterDocumentSchema.parse({ ...letter, format: "reactive-resume-cover-letter", version: 1 });
},
import: (input: { userId: string; document: CoverLetterDocument }) => {
const document = coverLetterDocumentSchema.parse(input.document);
return insert({ ...document, userId: input.userId });
},
};
+2
View File
@@ -3,6 +3,7 @@ import { aiRouter } from "../features/ai/router";
import { aiProvidersRouter } from "../features/ai-providers/router";
import { applicationsRouter } from "../features/applications/router";
import { authRouter } from "../features/auth/router";
import { coverLettersRouter } from "../features/cover-letters/router";
import { flagsRouter } from "../features/flags/router";
import { resumeRouter } from "../features/resume/router";
import { statisticsRouter } from "../features/statistics/router";
@@ -14,6 +15,7 @@ export default {
agent: agentRouter,
applications: applicationsRouter,
auth: authRouter,
coverLetters: coverLettersRouter,
flags: flagsRouter,
resume: resumeRouter,
statistics: statisticsRouter,
+35
View File
@@ -0,0 +1,35 @@
import type { CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
import * as pg from "drizzle-orm/pg-core";
import { generateId } from "@reactive-resume/utils/string";
import { application } from "./applications";
import { user } from "./auth";
import { resume } from "./resume";
export const coverLetter = pg.pgTable(
"cover_letter",
{
id: pg
.text("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
userId: pg
.text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: pg.text("name").notNull(),
recipient: pg.text("recipient").notNull().default(""),
content: pg.text("content").notNull().default(""),
style: pg.jsonb("style").$type<CoverLetterStyle>().notNull(),
sourceResumeId: pg.text("source_resume_id").references(() => resume.id, { onDelete: "set null" }),
sourceApplicationId: pg.text("source_application_id").references(() => application.id, { onDelete: "set null" }),
revision: pg.integer("revision").notNull().default(1),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [pg.index().on(table.userId, table.updatedAt.desc(), table.id.desc())],
);
+1
View File
@@ -1,4 +1,5 @@
export * from "./agent";
export * from "./applications";
export * from "./auth";
export * from "./cover-letter";
export * from "./resume";
+1
View File
@@ -4,6 +4,7 @@
"type": "module",
"private": true,
"exports": {
"./cover-letter": "./src/cover-letter.ts",
"./ats": "./src/ats/index.ts",
"./ats-pdf": "./src/ats-pdf/index.ts",
"./export-sections": "./src/export-sections.ts",
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { copyCoverLetterStyle, coverLetterTextToHtml, createCoverLetterResumeData } from "./cover-letter";
describe("independent cover letters", () => {
it("copies sender and style without linking source mutations or retaining private notes", () => {
const source = structuredClone(defaultResumeData);
source.basics.name = "Ada Lovelace";
source.metadata.notes = "Private interview notes";
const style = copyCoverLetterStyle(source, "old-section", "old-item");
source.basics.name = "Changed later";
source.metadata.page.marginX = 40;
expect(style.basics.name).toBe("Ada Lovelace");
expect(style.metadata.page.marginX).toBe(defaultResumeData.metadata.page.marginX);
expect(style.metadata).not.toHaveProperty("notes");
expect(style.metadata).not.toHaveProperty("layout");
expect(style).toMatchObject({ sectionId: "old-section", itemId: "old-item" });
});
it("renders only saved letter content while preserving sender and target identifiers", () => {
const style = copyCoverLetterStyle(defaultResumeData, "letter-section", "letter-item");
style.basics.name = "Ada";
const data = createCoverLetterResumeData({
name: "Example",
recipient: "<p>Recruiter</p>",
content: "<p>Hello</p>",
style,
});
expect(data.basics.name).toBe("Ada");
expect(data.metadata.notes).toBe("");
expect(Object.values(data.sections).every((section) => section.items.length === 0)).toBe(true);
expect(data.customSections).toHaveLength(1);
expect(data.customSections[0]).toMatchObject({
id: "letter-section",
type: "cover-letter",
items: [{ id: "letter-item", recipient: "<p>Recruiter</p>", content: "<p>Hello</p>" }],
});
expect(data.metadata.layout.pages).toEqual([{ fullWidth: true, main: ["letter-section"], sidebar: [] }]);
data.basics.name = "Independent render copy";
expect(style.basics.name).toBe("Ada");
});
it("escapes generated plain text before inserting paragraph markup", () => {
expect(coverLetterTextToHtml(" Hello <script>alert('x')</script> & team\nnext\n\nThanks\" ")).toBe(
"<p>Hello &lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; &amp; team<br />next</p><p>Thanks&quot;</p>",
);
expect(coverLetterTextToHtml(" ")).toBe("");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { CoverLetter, CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
export function copyCoverLetterStyle(
data: ResumeData,
sectionId = "library-cover-letter",
itemId = "library-cover-letter-item",
): CoverLetterStyle {
const { notes: _notes, layout: _layout, ...metadata } = data.metadata;
return structuredClone({ basics: data.basics, picture: data.picture, metadata, sectionId, itemId });
}
export function createCoverLetterResumeData(
letter: Pick<CoverLetter, "name" | "recipient" | "content" | "style">,
): ResumeData {
const data = structuredClone(defaultResumeData);
const style = structuredClone(letter.style);
data.basics = style.basics;
data.picture = style.picture;
data.metadata = {
...style.metadata,
notes: "",
layout: { sidebarWidth: 35, pages: [{ fullWidth: true, main: [style.sectionId], sidebar: [] }] },
};
data.customSections = [
{
id: style.sectionId,
type: "cover-letter",
title: letter.name,
icon: "envelope",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: style.itemId, hidden: false, recipient: letter.recipient, content: letter.content }],
},
];
return data;
}
export function coverLetterTextToHtml(text: string): string {
return text
.trim()
.split(/\n\s*\n/)
.filter(Boolean)
.map((paragraph) => {
const escaped = paragraph
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
return `<p>${escaped.replaceAll("\n", "<br />")}</p>`;
})
.join("");
}
+1
View File
@@ -4,6 +4,7 @@
"type": "module",
"private": true,
"exports": {
"./cover-letter/data": "./src/cover-letter/data.ts",
"./applications/*": "./src/applications/*.ts",
"./icons": "./src/icons.ts",
"./resume/stylesheet": "./src/resume/stylesheet.ts",
+35
View File
@@ -0,0 +1,35 @@
import z from "zod";
import { basicsSchema, metadataSchema, pictureSchema } from "../resume/data";
export const coverLetterStyleSchema = z.object({
basics: basicsSchema,
picture: pictureSchema,
metadata: metadataSchema.omit({ notes: true, layout: true }),
sectionId: z.string().min(1),
itemId: z.string().min(1),
});
export const coverLetterContentSchema = z.object({
name: z.string().trim().min(1).max(100),
recipient: z.string().max(20_000),
content: z.string().max(100_000),
style: coverLetterStyleSchema,
});
export const coverLetterSchema = coverLetterContentSchema.extend({
id: z.string(),
sourceResumeId: z.string().nullable(),
sourceApplicationId: z.string().nullable(),
revision: z.number().int().min(1),
createdAt: z.date(),
updatedAt: z.date(),
});
export const coverLetterDocumentSchema = coverLetterContentSchema.extend({
format: z.literal("reactive-resume-cover-letter"),
version: z.literal(1),
});
export type CoverLetterStyle = z.infer<typeof coverLetterStyleSchema>;
export type CoverLetter = z.infer<typeof coverLetterSchema>;
export type CoverLetterDocument = z.infer<typeof coverLetterDocumentSchema>;
+115
View File
@@ -274,6 +274,9 @@ importers:
resumable-stream:
specifier: ^2.2.12
version: 2.2.12
sanitize-html:
specifier: ^2.17.7
version: 2.17.7
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@26.4.1)
@@ -769,6 +772,9 @@ importers:
resumable-stream:
specifier: ^2.2.12
version: 2.2.12
sanitize-html:
specifier: ^2.17.7
version: 2.17.7
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@26.4.1)
@@ -788,9 +794,18 @@ importers:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/pg':
specifier: ^8.23.1
version: 8.23.1
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260707.2
version: 7.0.0-dev.20260707.2
pg:
specifier: ^8.23.0
version: 8.23.0
typescript:
specifier: ^7.0.2
version: 7.0.2
@@ -4696,6 +4711,9 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -5773,16 +5791,32 @@ packages:
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom-serializer@3.1.1:
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
engines: {node: '>=20.19.0'}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domelementtype@3.0.0:
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
engines: {node: '>=20.19.0'}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domhandler@6.0.1:
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
engines: {node: '>=20.19.0'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
domutils@4.0.2:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
dot-prop@10.2.0:
resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==}
engines: {node: '>=20'}
@@ -6061,6 +6095,10 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -6354,6 +6392,13 @@ packages:
html5parser@3.0.0:
resolution: {integrity: sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -6507,6 +6552,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.1.0:
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -6666,6 +6715,9 @@ packages:
launch-editor@2.14.1:
resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==}
launder@1.7.1:
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
@@ -7409,6 +7461,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
@@ -7946,6 +8001,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.17.7:
resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==}
engines: {node: '>=22.12.0'}
sax@1.6.1:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
@@ -12135,6 +12194,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sanitize-html@2.16.1':
dependencies:
htmlparser2: 10.1.0
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -12972,18 +13035,36 @@ snapshots:
domhandler: 5.0.3
entities: 4.5.0
dom-serializer@3.1.1:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
entities: 8.0.0
domelementtype@2.3.0: {}
domelementtype@3.0.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domhandler@6.0.1:
dependencies:
domelementtype: 3.0.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils@4.0.2:
dependencies:
dom-serializer: 3.1.1
domelementtype: 3.0.0
domhandler: 6.0.1
dot-prop@10.2.0:
dependencies:
type-fest: 5.9.0
@@ -13153,6 +13234,8 @@ snapshots:
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
esprima@4.0.1: {}
@@ -13508,6 +13591,20 @@ snapshots:
html5parser@3.0.0: {}
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
domutils: 4.0.2
entities: 8.0.0
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -13624,6 +13721,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-plain-object@5.1.0: {}
is-promise@4.0.0: {}
is-regexp@3.1.0: {}
@@ -13757,6 +13856,10 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.10.0
launder@1.7.1:
dependencies:
dayjs: 1.11.23
leac@0.6.0: {}
lefthook-darwin-arm64@2.1.12:
@@ -14724,6 +14827,8 @@ snapshots:
parse-ms@4.0.0: {}
parse-srcset@1.0.2: {}
parse-svg-path@0.1.2: {}
parseley@0.12.1:
@@ -15316,6 +15421,16 @@ snapshots:
safer-buffer@2.1.2: {}
sanitize-html@2.17.7:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 12.0.0
is-plain-object: 5.1.0
launder: 1.7.1
parse-srcset: 1.0.2
postcss: 8.5.28
sax@1.6.1: {}
scheduler@0.25.0-rc-603e6108-20241029: {}
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { Pool } from "pg";
import { createSampleResumeFromDashboard } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
test("edits the same library letter from the dashboard and builder, with independent JSON copies", async ({
authPage: page,
}, testInfo) => {
test.setTimeout(90_000);
await createSampleResumeFromDashboard(page, testInfo);
const builderUrl = page.url();
await page.goto("/dashboard/cover-letters");
await page.getByRole("button", { name: "Create", exact: true }).click();
await page.getByLabel("Name", { exact: true }).fill("Platform engineer letter");
await page.getByRole("button", { name: "Create cover letter", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Edit cover letter", exact: true });
await editor.getByLabel("Recipient", { exact: true }).fill("Dear hiring team,");
await editor.getByLabel("Content", { exact: true }).fill("I build reliable platforms for growing teams.");
await editor.getByRole("button", { name: "Save Changes", exact: true }).click();
await expect(editor.getByRole("button", { name: "Save Changes", exact: true })).toBeDisabled();
const jsonDownload = page.waitForEvent("download");
await editor.getByRole("button", { name: "Export JSON", exact: true }).click();
const json = await jsonDownload;
const jsonPath = await json.path();
if (!jsonPath) throw new Error("JSON download did not produce a file.");
const document = JSON.parse(await readFile(jsonPath, "utf8"));
expect(document.format).toBe("reactive-resume-cover-letter");
expect(document.content).toContain("reliable platforms");
expect(document).not.toHaveProperty("id");
await expect(editor.getByRole("button", { name: "Download PDF", exact: true })).toBeEnabled();
await editor.evaluate((element) => {
element.scrollTop = 0;
});
await page.screenshot({ path: testInfo.outputPath("cover-letter-editor.png") });
await editor.getByRole("button", { name: "Preview PDF", exact: true }).click();
await expect(editor.locator("canvas").first()).toBeVisible();
await editor.getByRole("button", { name: "Hide preview", exact: true }).click();
const pdfDownload = page.waitForEvent("download");
await editor.getByRole("button", { name: "Download PDF", exact: true }).click();
const pdf = await pdfDownload;
const pdfPath = await pdf.path();
if (!pdfPath) throw new Error("PDF download did not produce a file.");
expect((await readFile(pdfPath)).subarray(0, 5).toString()).toBe("%PDF-");
await editor.getByRole("button", { name: "Close", exact: true }).click();
await page.goto(builderUrl);
await page.getByRole("button", { name: "Cover-letter library", exact: true }).click();
await page.getByRole("button", { name: "Edit Platform engineer letter", exact: true }).click();
await expect(editor.getByLabel("Content", { exact: true })).toContainText("reliable platforms");
await editor.getByLabel("Content", { exact: true }).fill("Updated from the resume builder.");
await editor.getByRole("button", { name: "Save Changes", exact: true }).click();
await expect(editor.getByRole("button", { name: "Save Changes", exact: true })).toBeDisabled();
await editor.getByRole("button", { name: "Close", exact: true }).click();
await page
.getByRole("dialog", { name: "Cover letters", exact: true })
.getByRole("button", { name: "Close", exact: true })
.click();
await page.goto("/dashboard/cover-letters");
await page.getByRole("button", { name: "Edit Platform engineer letter", exact: true }).click();
await expect(editor.getByLabel("Content", { exact: true })).toContainText("Updated from the resume builder.");
await editor.getByRole("button", { name: "Close", exact: true }).click();
await page.getByLabel("Import cover letter JSON", { exact: true }).setInputFiles(jsonPath);
await expect(editor.getByLabel("Content", { exact: true })).toContainText("reliable platforms");
await editor.getByLabel("Name", { exact: true }).fill("Imported independent copy");
await editor.getByRole("button", { name: "Save Changes", exact: true }).click();
await expect(editor.getByRole("button", { name: "Save Changes", exact: true })).toBeDisabled();
await editor.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Edit Platform engineer letter", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Imported independent copy", exact: true })).toBeVisible();
});
test("keeps the application PDF snapshot after the library letter is deleted", async ({ authPage: page, account }) => {
test.setTimeout(60_000);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const applicationId = randomUUID();
try {
await pool.query(
'insert into application (id, user_id, company, role) select $1, id, $2, $3 from "user" where email = $4',
[applicationId, "Snapshot Company", "Platform Engineer", account.email],
);
await page.goto("/dashboard/cover-letters");
await page.getByRole("button", { name: "Create", exact: true }).click();
await page.getByLabel("Name", { exact: true }).fill("Snapshot letter");
await page.getByRole("button", { name: "Create cover letter", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Edit cover letter", exact: true });
await editor.getByLabel("Content", { exact: true }).fill("My application snapshot remains available.");
await editor.getByRole("button", { name: "Save Changes", exact: true }).click();
await expect(editor.getByRole("button", { name: "Save Changes", exact: true })).toBeDisabled();
await editor.getByLabel("Application", { exact: true }).click();
await page.getByRole("option", { name: "Snapshot Company — Platform Engineer", exact: true }).click();
await editor.getByRole("button", { name: "Attach PDF", exact: true }).click();
await expect(page.getByText("PDF snapshot attached to the application.", { exact: true })).toBeVisible();
const result = await pool.query<{ cover_letter_url: string }>(
"select cover_letter_url from application where id = $1",
[applicationId],
);
const url = result.rows[0]?.cover_letter_url;
if (!url) throw new Error("Cover-letter PDF was not attached.");
const before = await page.request.get(url);
expect(before.ok()).toBe(true);
const bytes = await before.body();
expect(bytes.subarray(0, 5).toString()).toBe("%PDF-");
await editor.getByRole("button", { name: "Delete", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Delete", exact: true }).click();
await expect(editor).not.toBeVisible();
await expect(page.getByRole("button", { name: "Edit Snapshot letter", exact: true })).not.toBeVisible();
const after = await page.request.get(url);
expect(after.ok()).toBe(true);
expect(await after.body()).toEqual(bytes);
await page.goto("/dashboard/applications");
await page
.getByRole("button", { name: /Platform Engineer.*Snapshot Company/ })
.first()
.click();
await expect(page.getByRole("dialog", { name: "Platform Engineer" }).locator(`a[href="${url}"]`)).toBeVisible();
} finally {
await pool.end();
}
});
test("blocks styling refresh when the current builder resume could not be saved", async ({
authPage: page,
}, testInfo) => {
await createSampleResumeFromDashboard(page, testInfo);
await page.getByRole("button", { name: "Cover-letter library", exact: true }).click();
const library = page.getByRole("dialog", { name: "Cover letters", exact: true });
await library.getByRole("button", { name: "Create", exact: true }).click();
await library.getByLabel("Name", { exact: true }).fill("Styling guard letter");
await library.getByRole("button", { name: "Create cover letter", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Edit cover letter", exact: true });
await editor.getByRole("button", { name: "Close", exact: true }).click();
await library.getByRole("button", { name: "Close", exact: true }).click();
await page.route("**/api/rpc/**", (route) => {
if (route.request().method() === "POST" && route.request().postData()?.includes("Unsaved sender"))
return route.abort();
return route.continue();
});
await page.getByLabel("Name", { exact: true }).fill("Unsaved sender");
await expect(page.getByText("Your latest changes could not be saved.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Cover-letter library", exact: true }).click();
await library.getByRole("button", { name: "Edit Styling guard letter", exact: true }).click();
await expect(editor.getByRole("button", { name: "Refresh from resume", exact: true })).toBeDisabled();
await expect(editor.getByRole("status")).toContainText("Save resume changes before copying its content or styling.");
});