mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
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:
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
exclude_paths:
|
||||||
|
- "migrations/**"
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
"react-email": "^6.9.3",
|
"react-email": "^6.9.3",
|
||||||
"react-pdf-html": "^2.1.5",
|
"react-pdf-html": "^2.1.5",
|
||||||
"resumable-stream": "^2.2.12",
|
"resumable-stream": "^2.2.12",
|
||||||
|
"sanitize-html": "^2.17.7",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"tokenx": "^2.1.0",
|
"tokenx": "^2.1.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
|
|||||||
@@ -618,6 +618,10 @@ msgstr "API Reference"
|
|||||||
msgid "App"
|
msgid "App"
|
||||||
msgstr "App"
|
msgstr "App"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/editor-dialog.tsx
|
||||||
|
msgid "Application"
|
||||||
|
msgstr "Application"
|
||||||
|
|
||||||
#: src/features/applications/components/application-actions-menu.tsx
|
#: src/features/applications/components/application-actions-menu.tsx
|
||||||
msgid "Application actions"
|
msgid "Application actions"
|
||||||
msgstr "Application actions"
|
msgstr "Application actions"
|
||||||
@@ -783,6 +787,10 @@ msgstr "Attach a resume file (PDF)"
|
|||||||
msgid "Attach files"
|
msgid "Attach files"
|
||||||
msgstr "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
|
#: src/routes/agent/-components/agent-chat.tsx
|
||||||
msgid "Attachment"
|
msgid "Attachment"
|
||||||
msgstr "Attachment"
|
msgstr "Attachment"
|
||||||
@@ -1007,6 +1015,10 @@ msgstr "Changed your mind? Rename your resume to something more descriptive."
|
|||||||
msgid "Changelog"
|
msgid "Changelog"
|
||||||
msgstr "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/-components/agent-chat.tsx
|
||||||
#: src/routes/agent/$threadId.tsx
|
#: src/routes/agent/$threadId.tsx
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
@@ -1056,6 +1068,10 @@ msgstr "Chinese (Simplified)"
|
|||||||
msgid "Chinese (Traditional)"
|
msgid "Chinese (Traditional)"
|
||||||
msgstr "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
|
#: src/routes/agent/-components/new-thread-setup.tsx
|
||||||
msgid "Choose a model and resume draft."
|
msgid "Choose a model and resume draft."
|
||||||
msgstr "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."
|
msgid "Choose a preset or custom shade."
|
||||||
msgstr "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
|
#: src/routes/agent/-components/new-thread-setup.tsx
|
||||||
msgid "Choose a resume"
|
msgid "Choose a resume"
|
||||||
msgstr "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
|
#: 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."
|
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."
|
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"
|
msgid "Client ID"
|
||||||
msgstr "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
|
#: src/routes/agent/-components/agent-chat.tsx
|
||||||
msgid "Close AI assistant"
|
msgid "Close AI assistant"
|
||||||
msgstr "Close AI assistant"
|
msgstr "Close AI assistant"
|
||||||
@@ -1218,6 +1244,7 @@ msgstr "Contacts"
|
|||||||
|
|
||||||
#: src/dialogs/resume/sections/cover-letter.tsx
|
#: src/dialogs/resume/sections/cover-letter.tsx
|
||||||
#: src/dialogs/resume/sections/summary-item.tsx
|
#: src/dialogs/resume/sections/summary-item.tsx
|
||||||
|
#: src/features/cover-letters/editor.tsx
|
||||||
msgid "Content"
|
msgid "Content"
|
||||||
msgstr "Content"
|
msgstr "Content"
|
||||||
|
|
||||||
@@ -1265,6 +1292,10 @@ msgstr "Copied to clipboard."
|
|||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr "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
|
#: src/dialogs/auth/enable-two-factor.tsx
|
||||||
msgid "Copy and store these backup codes in case you lose your device."
|
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."
|
msgstr "Copy and store these backup codes in case you lose your device."
|
||||||
@@ -1277,6 +1308,10 @@ msgstr "Copy Backup Codes"
|
|||||||
msgid "Copy JSON"
|
msgid "Copy JSON"
|
||||||
msgstr "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
|
#. Accessible label for the button that copies the two-factor secret key
|
||||||
#: src/dialogs/auth/enable-two-factor.tsx
|
#: src/dialogs/auth/enable-two-factor.tsx
|
||||||
msgid "Copy secret"
|
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."
|
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."
|
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
|
#: src/features/resume/export/use-resume-export.ts
|
||||||
msgid "Could not generate the DOCX. Please try again."
|
msgid "Could not generate the DOCX. Please try again."
|
||||||
msgstr "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."
|
msgid "Could not generate the PDF. Please try again."
|
||||||
msgstr "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
|
#: src/features/resume/export/use-resume-export.ts
|
||||||
msgid "Could not prepare your resume for printing. Please try again."
|
msgid "Could not prepare your resume for printing. Please try again."
|
||||||
msgstr "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."
|
msgid "Could not restore this patch."
|
||||||
msgstr "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
|
#: src/dialogs/auth/enable-two-factor.tsx
|
||||||
msgid "Could not set up two-factor authentication."
|
msgid "Could not set up two-factor authentication."
|
||||||
msgstr "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-detail-sheet.tsx
|
||||||
#: src/features/applications/components/application-form-sheet.tsx
|
#: src/features/applications/components/application-form-sheet.tsx
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
#: src/features/resume/export/download-dialog.tsx
|
#: src/features/resume/export/download-dialog.tsx
|
||||||
msgid "Cover letter"
|
msgid "Cover letter"
|
||||||
msgstr "Cover letter"
|
msgstr "Cover letter"
|
||||||
@@ -1413,6 +1470,16 @@ msgstr "Cover Letter"
|
|||||||
msgid "Cover letter draft"
|
msgid "Cover letter draft"
|
||||||
msgstr "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
|
#. Create API key dialog submit action
|
||||||
#: src/dialogs/api-key/create.tsx
|
#: src/dialogs/api-key/create.tsx
|
||||||
#: src/dialogs/resume/index.tsx
|
#: src/dialogs/resume/index.tsx
|
||||||
@@ -1431,6 +1498,7 @@ msgstr "Cover letter draft"
|
|||||||
#: src/dialogs/resume/sections/skill.tsx
|
#: src/dialogs/resume/sections/skill.tsx
|
||||||
#: src/dialogs/resume/sections/summary-item.tsx
|
#: src/dialogs/resume/sections/summary-item.tsx
|
||||||
#: src/dialogs/resume/sections/volunteer.tsx
|
#: src/dialogs/resume/sections/volunteer.tsx
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
#: src/routes/dashboard/resumes/index.tsx
|
#: src/routes/dashboard/resumes/index.tsx
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Create"
|
msgstr "Create"
|
||||||
@@ -1519,10 +1587,18 @@ msgstr "Create a new volunteer experience"
|
|||||||
msgid "Create a Sample Resume"
|
msgid "Create a Sample Resume"
|
||||||
msgstr "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
|
#: src/routes/_home/-sections/features.tsx
|
||||||
msgid "Create as many resumes as you want."
|
msgid "Create as many resumes as you want."
|
||||||
msgstr "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
|
#: src/routes/agent/-components/new-thread-setup.tsx
|
||||||
msgid "Create from scratch"
|
msgid "Create from scratch"
|
||||||
msgstr "Create from scratch"
|
msgstr "Create from scratch"
|
||||||
@@ -1656,6 +1732,10 @@ msgstr "Deep check"
|
|||||||
msgid "DeepSeek"
|
msgid "DeepSeek"
|
||||||
msgstr "DeepSeek"
|
msgstr "DeepSeek"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
|
msgid "Default styling"
|
||||||
|
msgstr "Default styling"
|
||||||
|
|
||||||
#: src/dialogs/resume/sections/education.tsx
|
#: src/dialogs/resume/sections/education.tsx
|
||||||
msgid "Degree"
|
msgid "Degree"
|
||||||
msgstr "Degree"
|
msgstr "Degree"
|
||||||
@@ -1669,6 +1749,7 @@ msgstr "Degree"
|
|||||||
#: src/features/applications/components/application-actions-menu.tsx
|
#: src/features/applications/components/application-actions-menu.tsx
|
||||||
#: src/features/applications/components/application-detail-sheet.tsx
|
#: src/features/applications/components/application-detail-sheet.tsx
|
||||||
#: src/features/applications/components/table-view.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/authentication/components/passkeys.tsx
|
||||||
#: src/features/settings/pages/api-keys.tsx
|
#: src/features/settings/pages/api-keys.tsx
|
||||||
#: src/routes/agent/-components/agent-chat.tsx
|
#: src/routes/agent/-components/agent-chat.tsx
|
||||||
@@ -1712,6 +1793,10 @@ msgstr "Delete this agent thread?"
|
|||||||
msgid "Delete this application?"
|
msgid "Delete this application?"
|
||||||
msgstr "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
|
#: src/features/applications/components/application-detail-sheet.tsx
|
||||||
msgid "Delete this timeline entry?"
|
msgid "Delete this timeline entry?"
|
||||||
msgstr "Delete this timeline entry?"
|
msgstr "Delete this timeline entry?"
|
||||||
@@ -1794,6 +1879,14 @@ msgstr "Disable Two-Factor Authentication"
|
|||||||
msgid "Disabling two-factor authentication..."
|
msgid "Disabling two-factor authentication..."
|
||||||
msgstr "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
|
#. Authentication settings action to unlink a connected social login provider
|
||||||
#: src/features/settings/authentication/components/social-provider.tsx
|
#: src/features/settings/authentication/components/social-provider.tsx
|
||||||
msgid "Disconnect"
|
msgid "Disconnect"
|
||||||
@@ -1862,6 +1955,7 @@ msgstr "Download CSV"
|
|||||||
msgid "Download options"
|
msgid "Download options"
|
||||||
msgstr "Download options"
|
msgstr "Download options"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/editor-dialog.tsx
|
||||||
#: src/features/resume/public/public-resume.tsx
|
#: src/features/resume/public/public-resume.tsx
|
||||||
#: src/routes/agent/-components/resume-pane.tsx
|
#: src/routes/agent/-components/resume-pane.tsx
|
||||||
msgid "Download PDF"
|
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 context menu action to create a copy
|
||||||
#. Resume card dropdown action to create a copy
|
#. Resume card dropdown action to create a copy
|
||||||
#: src/dialogs/resume/index.tsx
|
#: src/dialogs/resume/index.tsx
|
||||||
|
#: src/features/cover-letters/editor-dialog.tsx
|
||||||
#: src/routes/builder/$resumeId/-components/header.tsx
|
#: src/routes/builder/$resumeId/-components/header.tsx
|
||||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx
|
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx
|
||||||
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.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"
|
msgid "Edit"
|
||||||
msgstr "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.
|
#. Screen reader label for button that edits a keyword chip. Variable is the current keyword text.
|
||||||
#: src/components/input/chip-input.tsx
|
#: src/components/input/chip-input.tsx
|
||||||
msgid "Edit {chip}"
|
msgid "Edit {chip}"
|
||||||
@@ -1956,6 +2056,10 @@ msgstr "Edit color {0}"
|
|||||||
msgid "Edit content in fullscreen mode"
|
msgid "Edit content in fullscreen mode"
|
||||||
msgstr "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
|
#: src/features/applications/components/application-detail-sheet.tsx
|
||||||
msgid "Edit date"
|
msgid "Edit date"
|
||||||
msgstr "Edit date"
|
msgstr "Edit date"
|
||||||
@@ -2056,6 +2160,10 @@ msgstr "English"
|
|||||||
msgid "English (United Kingdom)"
|
msgid "English (United Kingdom)"
|
||||||
msgstr "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
|
#: src/features/settings/authentication/components/passkeys.tsx
|
||||||
msgid "Enter a name for your passkey."
|
msgid "Enter a name for your passkey."
|
||||||
msgstr "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."
|
msgid "Export from the original document instead of scanning or screenshotting it."
|
||||||
msgstr "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
|
#: src/features/settings/pages/account.tsx
|
||||||
msgid "Export my data"
|
msgid "Export my data"
|
||||||
msgstr "Export my data"
|
msgstr "Export my data"
|
||||||
@@ -2677,6 +2789,10 @@ msgstr "Hide password"
|
|||||||
msgid "Hide picture"
|
msgid "Hide picture"
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx
|
||||||
msgid "Hide Section Icons"
|
msgid "Hide Section Icons"
|
||||||
msgstr "Hide Section Icons"
|
msgstr "Hide Section Icons"
|
||||||
@@ -2754,6 +2870,10 @@ msgstr "Import {0}"
|
|||||||
msgid "Import an existing resume"
|
msgid "Import an existing resume"
|
||||||
msgstr "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
|
#: src/routes/dashboard/applications/index.tsx
|
||||||
msgid "Import CSV"
|
msgid "Import CSV"
|
||||||
msgstr "Import CSV"
|
msgstr "Import CSV"
|
||||||
@@ -2767,6 +2887,10 @@ msgstr "Import failed. Check the CSV and try again."
|
|||||||
msgid "Import from CSV"
|
msgid "Import from CSV"
|
||||||
msgstr "Import from CSV"
|
msgstr "Import from CSV"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
|
msgid "Import JSON"
|
||||||
|
msgstr "Import JSON"
|
||||||
|
|
||||||
#. placeholder {0}: result.imported
|
#. placeholder {0}: result.imported
|
||||||
#: src/features/applications/components/import-applications-sheet.tsx
|
#: src/features/applications/components/import-applications-sheet.tsx
|
||||||
msgid "Imported {0} application(s)."
|
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."
|
msgid "Keep access when you are not using the application."
|
||||||
msgstr "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
|
#: src/features/ats-checker/messages.ts
|
||||||
msgid "Keep it under 2.5 MB, usually by removing or shrinking images."
|
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."
|
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/applications/components/application-detail-sheet.tsx
|
||||||
#: src/features/ats-checker/parse-preview.tsx
|
#: src/features/ats-checker/parse-preview.tsx
|
||||||
#: src/features/auth/pages/register.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/features/settings/pages/profile.tsx
|
||||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
|
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
|
||||||
#: src/routes/dashboard/resumes/index.tsx
|
#: src/routes/dashboard/resumes/index.tsx
|
||||||
@@ -3462,6 +3592,10 @@ msgstr "New thread"
|
|||||||
msgid "New Thread"
|
msgid "New Thread"
|
||||||
msgstr "New Thread"
|
msgstr "New Thread"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
|
msgid "Next"
|
||||||
|
msgstr "Next"
|
||||||
|
|
||||||
#: src/routes/_home/-sections/features.tsx
|
#: src/routes/_home/-sections/features.tsx
|
||||||
msgid "No ads and no trackers, so nothing gets in your way."
|
msgid "No ads and no trackers, so nothing gets in your way."
|
||||||
msgstr "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."
|
msgid "No conventional section headings were found."
|
||||||
msgstr "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
|
#. Error shown when AI import endpoint returns no parsed resume data
|
||||||
#: src/dialogs/resume/import.tsx
|
#: src/dialogs/resume/import.tsx
|
||||||
msgid "No data was returned from the AI provider."
|
msgid "No data was returned from the AI provider."
|
||||||
@@ -3789,8 +3927,10 @@ msgid "Page"
|
|||||||
msgstr "Page"
|
msgstr "Page"
|
||||||
|
|
||||||
#. Layout editor page label with 1-based page number
|
#. Layout editor page label with 1-based page number
|
||||||
|
#. placeholder {0}: Math.floor(offset / 20) + 1
|
||||||
#. placeholder {0}: pageIndex + 1
|
#. placeholder {0}: pageIndex + 1
|
||||||
#. placeholder {0}: targetPageIndex + 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/left/shared/section-item.tsx
|
||||||
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx
|
||||||
msgid "Page {0}"
|
msgid "Page {0}"
|
||||||
@@ -3916,6 +4056,14 @@ msgstr "Patch rolled back."
|
|||||||
msgid "PDF"
|
msgid "PDF"
|
||||||
msgstr "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/education.tsx
|
||||||
#: src/dialogs/resume/sections/experience.tsx
|
#: src/dialogs/resume/sections/experience.tsx
|
||||||
#: src/dialogs/resume/sections/project.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."
|
msgid "Preview and export keep valid styles and ignore invalid styles."
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
|
||||||
msgid "Primary Color"
|
msgid "Primary Color"
|
||||||
msgstr "Primary Color"
|
msgstr "Primary Color"
|
||||||
@@ -4241,6 +4397,7 @@ msgid "Ready to activate with warnings"
|
|||||||
msgstr "Ready to activate with warnings"
|
msgstr "Ready to activate with warnings"
|
||||||
|
|
||||||
#: src/dialogs/resume/sections/cover-letter.tsx
|
#: src/dialogs/resume/sections/cover-letter.tsx
|
||||||
|
#: src/features/cover-letters/editor.tsx
|
||||||
msgid "Recipient"
|
msgid "Recipient"
|
||||||
msgstr "Recipient"
|
msgstr "Recipient"
|
||||||
|
|
||||||
@@ -4270,10 +4427,18 @@ msgstr "References"
|
|||||||
msgid "Referred by…, things to emphasize, etc."
|
msgid "Referred by…, things to emphasize, etc."
|
||||||
msgstr "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
|
#: src/features/settings/authentication/components/passkeys.tsx
|
||||||
msgid "Register New Device"
|
msgid "Register New Device"
|
||||||
msgstr "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
|
#: src/features/auth/pages/forgot-password.tsx
|
||||||
msgid "Remember your password? <0/>"
|
msgid "Remember your password? <0/>"
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/features/ats-checker/messages.ts
|
||||||
msgid "Replace icon fonts with plain text, then re-export."
|
msgid "Replace icon fonts with plain text, then re-export."
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/information.tsx
|
||||||
msgid "Report a Bug"
|
msgid "Report a Bug"
|
||||||
msgstr "Report a Bug"
|
msgstr "Report a Bug"
|
||||||
@@ -4438,6 +4611,11 @@ msgstr "Resume options"
|
|||||||
msgid "Resume patch"
|
msgid "Resume patch"
|
||||||
msgstr "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
|
#: src/dialogs/resume/template/gallery.tsx
|
||||||
msgid "Resume templates for different professions and tastes. Pick the one that suits you."
|
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."
|
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"
|
msgid "Resumes"
|
||||||
msgstr "Resumes"
|
msgstr "Resumes"
|
||||||
|
|
||||||
|
#: src/features/cover-letters/editor-dialog.tsx
|
||||||
|
#: src/features/cover-letters/library.tsx
|
||||||
#: src/routes/agent/-components/agent-chat.tsx
|
#: src/routes/agent/-components/agent-chat.tsx
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
@@ -4577,14 +4757,27 @@ msgstr "Save changes"
|
|||||||
#: src/dialogs/resume/sections/skill.tsx
|
#: src/dialogs/resume/sections/skill.tsx
|
||||||
#: src/dialogs/resume/sections/summary-item.tsx
|
#: src/dialogs/resume/sections/summary-item.tsx
|
||||||
#: src/dialogs/resume/sections/volunteer.tsx
|
#: src/dialogs/resume/sections/volunteer.tsx
|
||||||
|
#: src/features/cover-letters/editor.tsx
|
||||||
#: src/features/settings/pages/profile.tsx
|
#: src/features/settings/pages/profile.tsx
|
||||||
msgid "Save Changes"
|
msgid "Save Changes"
|
||||||
msgstr "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
|
#: src/features/settings/integrations/components/ai-section.tsx
|
||||||
msgid "Save model"
|
msgid "Save model"
|
||||||
msgstr "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
|
#: src/features/ats-checker/messages.ts
|
||||||
msgid "Save the file with every page upright."
|
msgid "Save the file with every page upright."
|
||||||
msgstr "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"
|
msgid "Search commands"
|
||||||
msgstr "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
|
#. Accessible label for icon picker search input
|
||||||
#. Placeholder text in icon picker search input
|
#. Placeholder text in icon picker search input
|
||||||
#: src/components/input/icon-picker.tsx
|
#: src/components/input/icon-picker.tsx
|
||||||
@@ -5211,6 +5412,14 @@ msgstr "Stylesheet editor"
|
|||||||
msgid "Stylesheet has fatal errors"
|
msgid "Stylesheet has fatal errors"
|
||||||
msgstr "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
|
#: src/routes/_home/-sections/footer.tsx
|
||||||
msgid "Subreddit"
|
msgid "Subreddit"
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/components/layout/not-found-screen.tsx
|
||||||
msgid "The page you're looking for may have been moved or no longer exists."
|
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."
|
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."
|
msgid "This connection request is invalid or has expired."
|
||||||
msgstr "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
|
#: 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."
|
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."
|
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."
|
msgid "This period starts in the future."
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-sidebar/left/index.tsx
|
||||||
msgid "This resume is locked"
|
msgid "This resume is locked"
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/routes/_home/-sections/donate.tsx
|
||||||
msgid "Your support keeps the project free and open to everyone, now and later."
|
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."
|
msgstr "Your support keeps the project free and open to everyone, now and later."
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const extensions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Props = UseEditorOptions & {
|
type Props = UseEditorOptions & {
|
||||||
|
"aria-label"?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
@@ -96,7 +97,15 @@ type Props = UseEditorOptions & {
|
|||||||
editorClassName?: string;
|
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 { i18n } = useLingui();
|
||||||
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
|
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
@@ -110,6 +119,7 @@ export function RichInput({ value, onChange, style, className, editorClassName,
|
|||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
|
||||||
spellcheck: "false",
|
spellcheck: "false",
|
||||||
"data-editor": "true",
|
"data-editor": "true",
|
||||||
"data-fullscreen": isFullscreen ? "true" : "false",
|
"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 type { Application } from "../types";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
@@ -7,71 +6,19 @@ import {
|
|||||||
CaretRightIcon,
|
CaretRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
EnvelopeSimpleIcon,
|
EnvelopeSimpleIcon,
|
||||||
FilePdfIcon,
|
|
||||||
MagicWandIcon,
|
MagicWandIcon,
|
||||||
PaperPlaneTiltIcon,
|
PaperPlaneTiltIcon,
|
||||||
SparkleIcon,
|
SparkleIcon,
|
||||||
SpinnerGapIcon,
|
SpinnerGapIcon,
|
||||||
} from "@phosphor-icons/react";
|
} 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 { useCallback, useState } from "react";
|
||||||
import { toast } from "@reactive-resume/ui/components/toast";
|
import { toast } from "@reactive-resume/ui/components/toast";
|
||||||
import { generateFilename } from "@reactive-resume/utils/file";
|
|
||||||
import { cn } from "@reactive-resume/utils/style";
|
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 { orpc } from "@/libs/orpc/client";
|
||||||
import { applicationsListQueryKey } from "../queries";
|
import { applicationsListQueryKey } from "../queries";
|
||||||
|
|
||||||
const COPILOT_COVER_LETTER_SECTION_ID = "copilot-cover-letter";
|
|
||||||
|
|
||||||
const escapeHtml = (value: string) =>
|
|
||||||
value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
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.
|
// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage.
|
||||||
function band(score: number) {
|
function band(score: number) {
|
||||||
if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
|
if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
|
||||||
@@ -156,11 +103,7 @@ type Props = { application: Application };
|
|||||||
export function ApplicationAiCopilot({ application }: Props) {
|
export function ApplicationAiCopilot({ application }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
|
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
|
||||||
const [isGeneratingCoverLetter, setIsGeneratingCoverLetter] = useState(false);
|
const [coverLetterId, setCoverLetterId] = useState<string | null>(null);
|
||||||
const { data: resume } = useQuery({
|
|
||||||
...orpc.resume.getById.queryOptions({ input: { id: application.resumeId ?? "" } }),
|
|
||||||
enabled: !!application.resumeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||||
@@ -186,56 +129,22 @@ export function ApplicationAiCopilot({ application }: Props) {
|
|||||||
);
|
);
|
||||||
const draftMessage = useMutation(
|
const draftMessage = useMutation(
|
||||||
orpc.applications.ai.draftMessage.mutationOptions({
|
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.` }),
|
onError: (error) => toast.add({ type: "error", description: error.message || t`Drafting failed.` }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const attachCoverLetter = useMutation(
|
const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending;
|
||||||
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 canScore = !!application.resumeId && !!application.jobDescription;
|
const canScore = !!application.resumeId && !!application.jobDescription;
|
||||||
const score = application.matchScore;
|
const score = application.matchScore;
|
||||||
const gaps = aiGaps(application);
|
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 () => {
|
const copyDraft = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
|
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.`,
|
description: t`Could not copy to clipboard. Please copy the text manually.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [draft?.text, toast, t]);
|
}, [draft?.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border border-primary/15 bg-primary/[0.04]">
|
<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 />}
|
icon={<EnvelopeSimpleIcon />}
|
||||||
title={<Trans>Draft a cover letter</Trans>}
|
title={<Trans>Draft a cover letter</Trans>}
|
||||||
description={t`From your resume and the posting`}
|
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" })}
|
onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })}
|
||||||
/>
|
/>
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={<PaperPlaneTiltIcon />}
|
icon={<PaperPlaneTiltIcon />}
|
||||||
title={<Trans>Draft a follow-up</Trans>}
|
title={<Trans>Draft a follow-up</Trans>}
|
||||||
description={t`A friendly nudge for the recruiter`}
|
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" })}
|
onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,23 +270,6 @@ export function ApplicationAiCopilot({ application }: Props) {
|
|||||||
>
|
>
|
||||||
<CopyIcon className="size-3.5" /> <Trans>Copy</Trans>
|
<CopyIcon className="size-3.5" /> <Trans>Copy</Trans>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground text-xs hover:text-foreground"
|
className="text-muted-foreground text-xs hover:text-foreground"
|
||||||
@@ -390,6 +284,7 @@ export function ApplicationAiCopilot({ application }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{coverLetterId && <CoverLetterEditorDialog letterId={coverLetterId} onClose={() => setCoverLetterId(null)} />}
|
||||||
</section>
|
</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 = {
|
type PdfViewerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
data: ResumeData;
|
data: ResumeData;
|
||||||
|
includeCoverLetterHeader?: boolean;
|
||||||
publicResume?: {
|
publicResume?: {
|
||||||
username: string;
|
username: string;
|
||||||
slug: 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 rootRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewerRef = useRef<HTMLDivElement>(null);
|
const viewerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -88,8 +89,12 @@ export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
|
|||||||
fileRef.current = null;
|
fileRef.current = null;
|
||||||
dispatch({ type: "resetForData" });
|
dispatch({ type: "resetForData" });
|
||||||
|
|
||||||
const createPdf = () =>
|
const createPdf = () => {
|
||||||
publicResume ? resolvePublicResumePdfBlob({ data, publicResume }) : createResumePdfBlob(data);
|
if (publicResume) return resolvePublicResumePdfBlob({ data, publicResume });
|
||||||
|
return includeCoverLetterHeader
|
||||||
|
? createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true })
|
||||||
|
: createResumePdfBlob(data);
|
||||||
|
};
|
||||||
|
|
||||||
void createPdf()
|
void createPdf()
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
@@ -108,7 +113,7 @@ export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
}, [data, publicResume]);
|
}, [data, publicResume, includeCoverLetterHeader]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fileVersion;
|
void fileVersion;
|
||||||
|
|||||||
@@ -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 AuthVerify2faBackupRouteImport } from "./routes/auth/verify-2fa-backup";
|
||||||
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
||||||
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index";
|
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 TemplatesSplatRouteImport } from "./routes/templates/$";
|
||||||
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
|
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
|
||||||
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
|
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
|
||||||
@@ -152,6 +153,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
|||||||
path: "/",
|
path: "/",
|
||||||
getParentRoute: () => DashboardRouteRoute,
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
} as any);
|
} as any);
|
||||||
|
const DashboardCoverLettersRoute = DashboardCoverLettersRouteImport.update({
|
||||||
|
id: "/cover-letters",
|
||||||
|
path: "/cover-letters",
|
||||||
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
|
} as any);
|
||||||
const TemplatesSplatRoute = TemplatesSplatRouteImport.update({
|
const TemplatesSplatRoute = TemplatesSplatRouteImport.update({
|
||||||
id: "/templates/$",
|
id: "/templates/$",
|
||||||
path: "/templates/$",
|
path: "/templates/$",
|
||||||
@@ -235,6 +241,7 @@ export interface FileRoutesByFullPath {
|
|||||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||||
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
||||||
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
||||||
|
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
|
||||||
"/templates/$": typeof TemplatesSplatRoute;
|
"/templates/$": typeof TemplatesSplatRoute;
|
||||||
"/agent/": typeof AgentIndexRoute;
|
"/agent/": typeof AgentIndexRoute;
|
||||||
"/auth/": typeof AuthIndexRoute;
|
"/auth/": typeof AuthIndexRoute;
|
||||||
@@ -264,6 +271,7 @@ export interface FileRoutesByTo {
|
|||||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||||
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
||||||
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
||||||
|
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
|
||||||
"/templates/$": typeof TemplatesSplatRoute;
|
"/templates/$": typeof TemplatesSplatRoute;
|
||||||
"/": typeof HomeIndexRoute;
|
"/": typeof HomeIndexRoute;
|
||||||
"/agent": typeof AgentIndexRoute;
|
"/agent": typeof AgentIndexRoute;
|
||||||
@@ -300,6 +308,7 @@ export interface FileRoutesById {
|
|||||||
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
"/auth/resume-password": typeof AuthResumePasswordRoute;
|
||||||
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
"/auth/verify-2fa": typeof AuthVerify2faRoute;
|
||||||
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
|
||||||
|
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
|
||||||
"/templates/$": typeof TemplatesSplatRoute;
|
"/templates/$": typeof TemplatesSplatRoute;
|
||||||
"/_home/": typeof HomeIndexRoute;
|
"/_home/": typeof HomeIndexRoute;
|
||||||
"/agent/": typeof AgentIndexRoute;
|
"/agent/": typeof AgentIndexRoute;
|
||||||
@@ -337,6 +346,7 @@ export interface FileRouteTypes {
|
|||||||
| "/auth/resume-password"
|
| "/auth/resume-password"
|
||||||
| "/auth/verify-2fa"
|
| "/auth/verify-2fa"
|
||||||
| "/auth/verify-2fa-backup"
|
| "/auth/verify-2fa-backup"
|
||||||
|
| "/dashboard/cover-letters"
|
||||||
| "/templates/$"
|
| "/templates/$"
|
||||||
| "/agent/"
|
| "/agent/"
|
||||||
| "/auth/"
|
| "/auth/"
|
||||||
@@ -366,6 +376,7 @@ export interface FileRouteTypes {
|
|||||||
| "/auth/resume-password"
|
| "/auth/resume-password"
|
||||||
| "/auth/verify-2fa"
|
| "/auth/verify-2fa"
|
||||||
| "/auth/verify-2fa-backup"
|
| "/auth/verify-2fa-backup"
|
||||||
|
| "/dashboard/cover-letters"
|
||||||
| "/templates/$"
|
| "/templates/$"
|
||||||
| "/"
|
| "/"
|
||||||
| "/agent"
|
| "/agent"
|
||||||
@@ -401,6 +412,7 @@ export interface FileRouteTypes {
|
|||||||
| "/auth/resume-password"
|
| "/auth/resume-password"
|
||||||
| "/auth/verify-2fa"
|
| "/auth/verify-2fa"
|
||||||
| "/auth/verify-2fa-backup"
|
| "/auth/verify-2fa-backup"
|
||||||
|
| "/dashboard/cover-letters"
|
||||||
| "/templates/$"
|
| "/templates/$"
|
||||||
| "/_home/"
|
| "/_home/"
|
||||||
| "/agent/"
|
| "/agent/"
|
||||||
@@ -584,6 +596,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof DashboardIndexRouteImport;
|
preLoaderRoute: typeof DashboardIndexRouteImport;
|
||||||
parentRoute: typeof DashboardRouteRoute;
|
parentRoute: typeof DashboardRouteRoute;
|
||||||
};
|
};
|
||||||
|
"/dashboard/cover-letters": {
|
||||||
|
id: "/dashboard/cover-letters";
|
||||||
|
path: "/cover-letters";
|
||||||
|
fullPath: "/dashboard/cover-letters";
|
||||||
|
preLoaderRoute: typeof DashboardCoverLettersRouteImport;
|
||||||
|
parentRoute: typeof DashboardRouteRoute;
|
||||||
|
};
|
||||||
"/templates/$": {
|
"/templates/$": {
|
||||||
id: "/templates/$";
|
id: "/templates/$";
|
||||||
path: "/templates/$";
|
path: "/templates/$";
|
||||||
@@ -725,6 +744,7 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface DashboardRouteRouteChildren {
|
interface DashboardRouteRouteChildren {
|
||||||
|
DashboardCoverLettersRoute: typeof DashboardCoverLettersRoute;
|
||||||
DashboardIndexRoute: typeof DashboardIndexRoute;
|
DashboardIndexRoute: typeof DashboardIndexRoute;
|
||||||
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
|
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
|
||||||
DashboardSettingsAccountRoute: typeof DashboardSettingsAccountRoute;
|
DashboardSettingsAccountRoute: typeof DashboardSettingsAccountRoute;
|
||||||
@@ -738,6 +758,7 @@ interface DashboardRouteRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||||
|
DashboardCoverLettersRoute: DashboardCoverLettersRoute,
|
||||||
DashboardIndexRoute: DashboardIndexRoute,
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
DashboardSettingsIntegrationsRouteRoute:
|
DashboardSettingsIntegrationsRouteRoute:
|
||||||
DashboardSettingsIntegrationsRouteRoute,
|
DashboardSettingsIntegrationsRouteRoute,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { Separator } from "@reactive-resume/ui/components/separator";
|
|||||||
import { toast } from "@reactive-resume/ui/components/toast";
|
import { toast } from "@reactive-resume/ui/components/toast";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||||
import { getInitials } from "@reactive-resume/utils/string";
|
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 { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||||
import { orpc } from "@/libs/orpc/client";
|
import { orpc } from "@/libs/orpc/client";
|
||||||
@@ -60,6 +61,8 @@ function getSectionComponent(type: LeftSidebarSection) {
|
|||||||
export function BuilderSidebarLeft() {
|
export function BuilderSidebarLeft() {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isLocked = useIsResumeLocked();
|
const isLocked = useIsResumeLocked();
|
||||||
|
const resume = useCurrentResume();
|
||||||
|
const resumeReady = useResumeStore((state) => state.saveStatus !== "saving" && state.saveStatus !== "error");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -68,6 +71,7 @@ export function BuilderSidebarLeft() {
|
|||||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
|
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{isLocked && <LockBanner />}
|
{isLocked && <LockBanner />}
|
||||||
|
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
|
||||||
|
|
||||||
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||||
{leftSidebarSections.map((section) => (
|
{leftSidebarSections.map((section) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
BrainIcon,
|
BrainIcon,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
ChatCircleDotsIcon,
|
ChatCircleDotsIcon,
|
||||||
|
EnvelopeSimpleIcon,
|
||||||
GearSixIcon,
|
GearSixIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -57,6 +58,11 @@ const appSidebarItems = [
|
|||||||
label: msg`Applications`,
|
label: msg`Applications`,
|
||||||
href: "/dashboard/applications",
|
href: "/dashboard/applications",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <EnvelopeSimpleIcon />,
|
||||||
|
label: msg`Cover letters`,
|
||||||
|
href: "/dashboard/cover-letters",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <ChatCircleDotsIcon />,
|
icon: <ChatCircleDotsIcon />,
|
||||||
label: msg`Agents`,
|
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
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.
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"react-email",
|
"react-email",
|
||||||
"react-pdf-html",
|
"react-pdf-html",
|
||||||
"resumable-stream",
|
"resumable-stream",
|
||||||
|
"sanitize-html",
|
||||||
"sharp",
|
"sharp",
|
||||||
"tokenx",
|
"tokenx",
|
||||||
"ts-pattern",
|
"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
@@ -57,6 +57,7 @@
|
|||||||
"ollama-ai-provider-v2": "^4.0.1",
|
"ollama-ai-provider-v2": "^4.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"resumable-stream": "^2.2.12",
|
"resumable-stream": "^2.2.12",
|
||||||
|
"sanitize-html": "^2.17.7",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"tokenx": "^2.1.0",
|
"tokenx": "^2.1.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
@@ -65,7 +66,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@reactive-resume/config": "workspace:*",
|
"@reactive-resume/config": "workspace:*",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@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",
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"typescript": "^7.0.2"
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <team></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/service", () => ({ getModel: vi.fn() }));
|
||||||
vi.mock("../ai-providers/service", () => ({ aiProvidersService: { getDefaultRunnable: vi.fn() } }));
|
vi.mock("../ai-providers/service", () => ({ aiProvidersService: { getDefaultRunnable: vi.fn() } }));
|
||||||
vi.mock("../resume/service", () => ({ resumeService: { getById: vi.fn(), create: 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", () => ({
|
vi.mock("./service", () => ({
|
||||||
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { ORPCError } from "@orpc/client";
|
import { ORPCError } from "@orpc/client";
|
||||||
import { APICallError, generateText, RetryError } from "ai";
|
import { APICallError, generateText, RetryError } from "ai";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { coverLetterTextToHtml } from "@reactive-resume/resume/cover-letter";
|
||||||
import { generateId, slugify } from "@reactive-resume/utils/string";
|
import { generateId, slugify } from "@reactive-resume/utils/string";
|
||||||
import { protectedProcedure } from "../../context";
|
import { protectedProcedure } from "../../context";
|
||||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||||
import { generateJson as sharedGenerateJson } from "../ai/generate-json";
|
import { generateJson as sharedGenerateJson } from "../ai/generate-json";
|
||||||
import { getModel } from "../ai/service";
|
import { getModel } from "../ai/service";
|
||||||
import { aiProvidersService } from "../ai-providers/service";
|
import { aiProvidersService } from "../ai-providers/service";
|
||||||
|
import { coverLetterService } from "../cover-letters/service";
|
||||||
import { resumeService } from "../resume/service";
|
import { resumeService } from "../resume/service";
|
||||||
import { applicationService } from "./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"]) }))
|
.input(z.object({ id: z.string(), kind: z.enum(["cover-letter", "follow-up"]) }))
|
||||||
.use(aiRequestRateLimit)
|
.use(aiRequestRateLimit)
|
||||||
.output(z.object({ text: z.string() }))
|
.output(z.object({ text: z.string(), coverLetterId: z.string().optional() }))
|
||||||
.errors(aiErrors)
|
.errors(aiErrors)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
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 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_}`;
|
: `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.
|
// 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export const authRouter = {
|
|||||||
operationId: "exportAccountData",
|
operationId: "exportAccountData",
|
||||||
summary: "Export user account data",
|
summary: "Export user account data",
|
||||||
description:
|
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.",
|
successDescription: "The user's exported account data.",
|
||||||
})
|
})
|
||||||
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
|
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { db } from "@reactive-resume/db/client";
|
import { db } from "@reactive-resume/db/client";
|
||||||
import * as schema from "@reactive-resume/db/schema";
|
import * as schema from "@reactive-resume/db/schema";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
|
||||||
import { getStorageService } from "../storage/service";
|
import { getStorageService } from "../storage/service";
|
||||||
|
|
||||||
export type ProviderList = Partial<Record<AuthProvider, string>>;
|
export type ProviderList = Partial<Record<AuthProvider, string>>;
|
||||||
@@ -59,7 +60,13 @@ export const authService = {
|
|||||||
.from(schema.resume)
|
.from(schema.resume)
|
||||||
.where(eq(schema.resume.userId, input.userId));
|
.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> => {
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { aiRouter } from "../features/ai/router";
|
|||||||
import { aiProvidersRouter } from "../features/ai-providers/router";
|
import { aiProvidersRouter } from "../features/ai-providers/router";
|
||||||
import { applicationsRouter } from "../features/applications/router";
|
import { applicationsRouter } from "../features/applications/router";
|
||||||
import { authRouter } from "../features/auth/router";
|
import { authRouter } from "../features/auth/router";
|
||||||
|
import { coverLettersRouter } from "../features/cover-letters/router";
|
||||||
import { flagsRouter } from "../features/flags/router";
|
import { flagsRouter } from "../features/flags/router";
|
||||||
import { resumeRouter } from "../features/resume/router";
|
import { resumeRouter } from "../features/resume/router";
|
||||||
import { statisticsRouter } from "../features/statistics/router";
|
import { statisticsRouter } from "../features/statistics/router";
|
||||||
@@ -14,6 +15,7 @@ export default {
|
|||||||
agent: agentRouter,
|
agent: agentRouter,
|
||||||
applications: applicationsRouter,
|
applications: applicationsRouter,
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
|
coverLetters: coverLettersRouter,
|
||||||
flags: flagsRouter,
|
flags: flagsRouter,
|
||||||
resume: resumeRouter,
|
resume: resumeRouter,
|
||||||
statistics: statisticsRouter,
|
statistics: statisticsRouter,
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
export * from "./agent";
|
export * from "./agent";
|
||||||
export * from "./applications";
|
export * from "./applications";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./cover-letter";
|
||||||
export * from "./resume";
|
export * from "./resume";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./cover-letter": "./src/cover-letter.ts",
|
||||||
"./ats": "./src/ats/index.ts",
|
"./ats": "./src/ats/index.ts",
|
||||||
"./ats-pdf": "./src/ats-pdf/index.ts",
|
"./ats-pdf": "./src/ats-pdf/index.ts",
|
||||||
"./export-sections": "./src/export-sections.ts",
|
"./export-sections": "./src/export-sections.ts",
|
||||||
|
|||||||
@@ -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 <script>alert('x')</script> & team<br />next</p><p>Thanks"</p>",
|
||||||
|
);
|
||||||
|
expect(coverLetterTextToHtml(" ")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
return `<p>${escaped.replaceAll("\n", "<br />")}</p>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./cover-letter/data": "./src/cover-letter/data.ts",
|
||||||
"./applications/*": "./src/applications/*.ts",
|
"./applications/*": "./src/applications/*.ts",
|
||||||
"./icons": "./src/icons.ts",
|
"./icons": "./src/icons.ts",
|
||||||
"./resume/stylesheet": "./src/resume/stylesheet.ts",
|
"./resume/stylesheet": "./src/resume/stylesheet.ts",
|
||||||
|
|||||||
@@ -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>;
|
||||||
Generated
+115
@@ -274,6 +274,9 @@ importers:
|
|||||||
resumable-stream:
|
resumable-stream:
|
||||||
specifier: ^2.2.12
|
specifier: ^2.2.12
|
||||||
version: 2.2.12
|
version: 2.2.12
|
||||||
|
sanitize-html:
|
||||||
|
specifier: ^2.17.7
|
||||||
|
version: 2.17.7
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.35.4
|
specifier: ^0.35.4
|
||||||
version: 0.35.4(@types/node@26.4.1)
|
version: 0.35.4(@types/node@26.4.1)
|
||||||
@@ -769,6 +772,9 @@ importers:
|
|||||||
resumable-stream:
|
resumable-stream:
|
||||||
specifier: ^2.2.12
|
specifier: ^2.2.12
|
||||||
version: 2.2.12
|
version: 2.2.12
|
||||||
|
sanitize-html:
|
||||||
|
specifier: ^2.17.7
|
||||||
|
version: 2.17.7
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.35.4
|
specifier: ^0.35.4
|
||||||
version: 0.35.4(@types/node@26.4.1)
|
version: 0.35.4(@types/node@26.4.1)
|
||||||
@@ -788,9 +794,18 @@ importers:
|
|||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 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':
|
'@typescript/native-preview':
|
||||||
specifier: 7.0.0-dev.20260707.2
|
specifier: 7.0.0-dev.20260707.2
|
||||||
version: 7.0.0-dev.20260707.2
|
version: 7.0.0-dev.20260707.2
|
||||||
|
pg:
|
||||||
|
specifier: ^8.23.0
|
||||||
|
version: 8.23.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
@@ -4696,6 +4711,9 @@ packages:
|
|||||||
'@types/react@19.2.18':
|
'@types/react@19.2.18':
|
||||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
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':
|
'@types/unist@2.0.11':
|
||||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
@@ -5773,16 +5791,32 @@ packages:
|
|||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
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:
|
domelementtype@2.3.0:
|
||||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
|
domelementtype@3.0.0:
|
||||||
|
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
domhandler@5.0.3:
|
domhandler@5.0.3:
|
||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
domhandler@6.0.1:
|
||||||
|
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
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:
|
dot-prop@10.2.0:
|
||||||
resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==}
|
resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -6061,6 +6095,10 @@ packages:
|
|||||||
escape-html@1.0.3:
|
escape-html@1.0.3:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
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:
|
escape-string-regexp@5.0.0:
|
||||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6354,6 +6392,13 @@ packages:
|
|||||||
html5parser@3.0.0:
|
html5parser@3.0.0:
|
||||||
resolution: {integrity: sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==}
|
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:
|
htmlparser2@8.0.2:
|
||||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||||
|
|
||||||
@@ -6507,6 +6552,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||||
engines: {node: '>=12'}
|
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:
|
is-promise@4.0.0:
|
||||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||||
|
|
||||||
@@ -6666,6 +6715,9 @@ packages:
|
|||||||
launch-editor@2.14.1:
|
launch-editor@2.14.1:
|
||||||
resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==}
|
resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==}
|
||||||
|
|
||||||
|
launder@1.7.1:
|
||||||
|
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
||||||
|
|
||||||
leac@0.6.0:
|
leac@0.6.0:
|
||||||
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
||||||
|
|
||||||
@@ -7409,6 +7461,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
|
||||||
parse-svg-path@0.1.2:
|
parse-svg-path@0.1.2:
|
||||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||||
|
|
||||||
@@ -7946,6 +8001,10 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sanitize-html@2.17.7:
|
||||||
|
resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==}
|
||||||
|
engines: {node: '>=22.12.0'}
|
||||||
|
|
||||||
sax@1.6.1:
|
sax@1.6.1:
|
||||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||||
engines: {node: '>=11.0.0'}
|
engines: {node: '>=11.0.0'}
|
||||||
@@ -12135,6 +12194,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.1':
|
||||||
|
dependencies:
|
||||||
|
htmlparser2: 10.1.0
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
@@ -12972,18 +13035,36 @@ snapshots:
|
|||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
entities: 4.5.0
|
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@2.3.0: {}
|
||||||
|
|
||||||
|
domelementtype@3.0.0: {}
|
||||||
|
|
||||||
domhandler@5.0.3:
|
domhandler@5.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
domhandler@6.0.1:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 3.0.0
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-serializer: 2.0.0
|
dom-serializer: 2.0.0
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
domhandler: 5.0.3
|
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:
|
dot-prop@10.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 5.9.0
|
type-fest: 5.9.0
|
||||||
@@ -13153,6 +13234,8 @@ snapshots:
|
|||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
escape-string-regexp@5.0.0: {}
|
escape-string-regexp@5.0.0: {}
|
||||||
|
|
||||||
esprima@4.0.1: {}
|
esprima@4.0.1: {}
|
||||||
@@ -13508,6 +13591,20 @@ snapshots:
|
|||||||
|
|
||||||
html5parser@3.0.0: {}
|
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:
|
htmlparser2@8.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -13624,6 +13721,8 @@ snapshots:
|
|||||||
|
|
||||||
is-plain-obj@4.1.0: {}
|
is-plain-obj@4.1.0: {}
|
||||||
|
|
||||||
|
is-plain-object@5.1.0: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
|
|
||||||
is-regexp@3.1.0: {}
|
is-regexp@3.1.0: {}
|
||||||
@@ -13757,6 +13856,10 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
shell-quote: 1.10.0
|
shell-quote: 1.10.0
|
||||||
|
|
||||||
|
launder@1.7.1:
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.23
|
||||||
|
|
||||||
leac@0.6.0: {}
|
leac@0.6.0: {}
|
||||||
|
|
||||||
lefthook-darwin-arm64@2.1.12:
|
lefthook-darwin-arm64@2.1.12:
|
||||||
@@ -14724,6 +14827,8 @@ snapshots:
|
|||||||
|
|
||||||
parse-ms@4.0.0: {}
|
parse-ms@4.0.0: {}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2: {}
|
||||||
|
|
||||||
parse-svg-path@0.1.2: {}
|
parse-svg-path@0.1.2: {}
|
||||||
|
|
||||||
parseley@0.12.1:
|
parseley@0.12.1:
|
||||||
@@ -15316,6 +15421,16 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
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: {}
|
sax@1.6.1: {}
|
||||||
|
|
||||||
scheduler@0.25.0-rc-603e6108-20241029: {}
|
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.");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user