fix: finalize v6 migrations and document workflows

This commit is contained in:
Amruth Pillai
2026-10-01 07:06:15 +02:00
parent 8105889b0c
commit bc92b8cf7f
177 changed files with 78971 additions and 131255 deletions
+3 -3
View File
@@ -4,6 +4,9 @@ PORT="3000"
# Port used by the Hono server in local development. Vite proxies API requests to this port.
SERVER_PORT="3001"
# Optional comma-separated proxy IPs/CIDRs. Only these socket peers may supply X-Forwarded-For.
# Configure your reverse proxy to append the actual client address; never trust a public client network.
TRUSTED_PROXIES=""
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
# OpenGraph metadata, and absolute upload URLs.
@@ -115,9 +118,6 @@ ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# Optional custom Firecrawl service, without /v2; may be keyless. Other providers use fixed cloud endpoints.
# WEB_ACCESS_PROVIDER="firecrawl"
# WEB_ACCESS_API_URL="http://localhost:3102"
# Legacy aliases still work when all WEB_ACCESS_* variables are unset.
# FIRECRAWL_API_URL="http://localhost:3102"
# FIRECRAWL_API_KEY=""
# Optional shared AI provider. When set, personal AI providers are disabled.
# AI_PROVIDER="openai"
+54
View File
@@ -21,6 +21,19 @@ the catalog already uses one consistently):
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
Also keep LinkedIn, Discord, Figma, Claude Desktop, PDF.js, models.dev, iOS, MIT License,
and WCAG 2.1 AA unchanged. **Word**, when naming an import or download format, means
**Microsoft Word**; do not translate it as the ordinary noun "word".
Names in sample documents and job postings are proper nouns too: Alex Morgan, Amruth Pillai,
Fieldnote, Northwind Labs, Parcel & Co., Lumen, and University of Porto. Keep these names
unchanged, including inside longer sentences. Translate surrounding job titles and descriptions,
not the person's, company's, or institution's name. Preserve place names such as Lisbon, Porto,
and Portugal in these examples as well.
Configuration identifiers such as `ENCRYPTION_SECRET`, file extensions, example email addresses,
and literal URLs are syntax, not translatable prose. Keep them exactly as written.
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
Fireworks, Cerebras, Perplexity, Ollama.
@@ -40,6 +53,9 @@ Where a locale's normal word for this document is CV, use CV.
**Cover letter** — the letter accompanying a resume. Stored as its own document, with optional links to a resume and an application.
**Letter / Letters** — shorthand for cover letter(s) in the document library and letter editor.
Not an alphabetic character.
**Builder** — the editor where a resume is composed. A tool, not a construction worker or a
person who builds.
@@ -94,6 +110,20 @@ Not a theatre stage or a phase of construction.
**Source** — where the user found the job listing (a job board, a referral, a company site).
Singular, and specific to one application. Not a source code file and not a data source.
**Posting / Job posting** — the employer's advertisement for an open position, including its
description and requirements. Not a social-media post, a postal delivery, or a transaction entry.
`Posting terms` are keywords from that advertisement, not terms and conditions.
**Role** — a job position, either the position being applied for or a past position in Experience.
Not a theatrical role or an account permission. `Contact role` describes the contact person's job,
such as recruiter or hiring manager.
**Follow up / Follow-up** — contacting a recruiter again about an application, or the reminder
to do so. `Follow up` is a button action; `Set a follow-up` schedules that reminder.
**Screening** — an initial interview to assess a candidate, often a short phone call. Not a
medical screening or a display screen.
**Pipeline** — the sequence of stages an application moves through. A recruiting funnel, not a
physical pipe, duct, conduit, or oil pipeline. Seven locales translated it as plumbing.
@@ -169,6 +199,12 @@ not a written note. Unrelated to **Notes** in the application tracker.
**Parse / parsing** — software reading text out of the PDF.
**Bullet / Bullets** — a list entry describing experience or an achievement; sometimes its list
marker. Never ammunition. `Find weak bullets` asks for review of the writing in those entries.
**Issue / Issues** — findings that may make a resume hard for software to read. Not a magazine
edition or a GitHub issue. `Open issues` means unresolved findings.
## Account and security
**Passkey / Passkeys** — a WebAuthn credential that replaces a password, stored on the user's
@@ -243,6 +279,24 @@ another offer accepted, no response). Not "shut" or "locked".
**System** — in Appearance, the option that follows the operating system's light or dark setting.
**Type**, in Design or `Font pairing` descriptions — typography: the chosen fonts and their
appearance. Not a document category, a personality type, or the verb "to type". `Document type`
and `Interview type` do mean categories; `Type … to confirm` is the verb for entering text.
**Accent / Accent colour** — the visual highlight colour used for headings and icons. Not a
pronunciation accent or an accented letter.
**Fit**, in `Fit to one page`, `Fit page to width`, and similar layout controls — make the
document fit within a page count or the available display width. Distinct from suitability for a
job in `How well do I fit this role?`.
**Present**, in date ranges such as `2021 – Present` — continuing up to now, for a current job
or education entry. Not a gift, attendance status, or the verb "to present".
**Sent / Submitted** — documents actually used when submitting a job application. Merely linking
a resume or letter to the application does not mean it was sent. `Version sent` is the saved
document state used for that submission.
## Verbs that read as adjectives or nouns
Button labels and `aria-label` strings are usually **imperative verbs**: they say what the
+3 -3
View File
@@ -235,7 +235,7 @@ Today `cover_letter` has name, recipient (rich-text HTML), content (HTML), `styl
| M8 | `application.closed_reason`, `cover_letter_id`, `sent_*`, `requirements` | `rejected`/`archived` → `closed` | Reverse script |
| M9 | `cover_letter.sender_linked`, `design_linked`, `recipient_name`, `recipient_company`, `letter_date`, `layout`; `cover_letter_version` | Existing letters → freeform, unlinked | Additive |
Contract steps (approved on 29 Sep 2026, migration `20260929063245_contract_redesign_legacy_fields`, with `rollback.sql`):
Contract steps (approved on 29 Sep 2026, migration `20261001042749_v6_release`, with `rollback.sql`):
- **`period`/`date` are written from `dates` only.** The text stays in the data as printed output, rewritten from the dates on every save; an edit to the text alone is overwritten. Entries without dates (imports, older data) still get dates from their text.
- **`application.archived` is dropped.** Rows still archived are closed first. The list, bulk update, MCP tools and CSV export lose the flag; CSV import still reads it from older exports as closed.
@@ -1602,9 +1602,9 @@ The items left open in §12 and §13, in the order they were done. Local commits
- **Preview in a Web Worker.** Every browser PDF (preview, thumbnails, downloads, Check, public page) renders in one module worker. If workers are unavailable it falls back to the main thread. Forme and its 6.9 MB engine stay out of the main bundle. The worker bundle gets the Lingui plugins for translated section titles.
- **List markers stay with their first line.** Converted list items carry their index into Forme's layout. `renderResume` finds markers left on a page their first line leaves and renders again with a page break before those items (at most three passes). Eleven expected-failure tests now pass. Presence hints are still ignored.
- **Right to left:** the award title and date row mirrors like every other header row.
- **§3.10 contract steps**, listed in §3.10. Migration `20260929063245_contract_redesign_legacy_fields` has a `rollback.sql`. It was applied only to the isolated verification database.
- **§3.10 contract steps**, listed in §3.10. Migration `20261001042749_v6_release` has a `rollback.sql`. It was applied only to the isolated verification database.
- **Letters leave resumes.** A cover letter is a document of its own; resumes no longer hold cover-letter sections.
- Migration `20260929071322_letters_leave_resumes` (with `rollback.sql`) saves every letter a resume carried (hidden ones too) as a letter linked to that resume's details and design, named "‹resume› — ‹section›". A resume with one letter, used by exactly one letter-less application, hands the letter to that application. The sections then leave the resumes and their layouts. Resume History keeps older versions as they were.
- Migration `20261001042749_v6_release` (with `rollback.sql`) saves every letter a resume carried (hidden ones too) as a letter linked to that resume's details and design, named "‹resume› — ‹section›". A resume with one letter, used by exactly one letter-less application, hands the letter to that application. The sections then leave the resumes and their layouts. Resume History keeps older versions as they were.
- Every resume write on the server (create, import, update, patch, restore) passes through `adoptEmbeddedLetters`, in the same transaction, so a stale tab, an old file, an API client or a restored version still can't put a letter back into a resume. A letter already saved from the same item with the same text isn't saved twice.
- Removed: adding a cover letter in the resume editor, "Copy to Documents", "Import from library", the resume Download's Cover letter tab, the `cover-letter` target of resume PDF downloads (API, signed links, MCP) and `copy_embedded_cover_letter`. The sample resume has no letter. Letters still render through the templates as a cover-letter section of their own document.
- Letters moved by the migration are stored as they were written; the server sanitises them on their next save.
+3 -4
View File
@@ -30,13 +30,13 @@
"@ai-sdk/xai": "^5.0.14",
"@aws-sdk/client-s3": "^3.1144.0",
"@better-auth/api-key": "catalog:",
"@better-auth/drizzle-adapter": "^1.7.7",
"@better-auth/drizzle-adapter": "1.7.7",
"@better-auth/infra": "catalog:",
"@better-auth/oauth-provider": "catalog:",
"@better-auth/passkey": "catalog:",
"@bramus/specificity": "^2.4.2",
"@formepdf/core": "0.25.0",
"@formepdf/react": "0.25.0",
"@formepdf/core": "0.26.0",
"@formepdf/react": "0.26.0",
"@hono/node-server": "^2.1.3",
"@modelcontextprotocol/sdk": "^1.31.0",
"@orpc/client": "catalog:",
@@ -68,7 +68,6 @@
"fast-json-patch": "^3.1.1",
"fast-png": "^8.0.0",
"fflate": "catalog:",
"firecrawl": "^4.42.1",
"hono": "^4.13.12",
"ioredis": "catalog:",
"jose": "^6.2.12",
+25
View File
@@ -2,6 +2,7 @@ import { gunzipSync } from "node:zlib";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
trustedProxies: [] as string[],
handleAuth: vi.fn(),
handleOAuth: vi.fn(),
handleRpc: vi.fn(),
@@ -23,6 +24,11 @@ const mocks = vi.hoisted(() => ({
handleWebApp: vi.fn(),
}));
vi.mock("@reactive-resume/env/server", async (original) => {
const { env } = await original<typeof import("@reactive-resume/env/server")>();
return { env: { ...env, TRUSTED_PROXIES: mocks.trustedProxies } };
});
vi.mock("./auth", () => ({
handleAuth: mocks.handleAuth,
handleOAuth: mocks.handleOAuth,
@@ -82,6 +88,7 @@ const transportEnv = (remoteAddress: string) =>
beforeEach(() => {
vi.clearAllMocks();
mocks.trustedProxies.length = 0;
mocks.handleAuth.mockResolvedValue(new Response("auth"));
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
@@ -104,6 +111,24 @@ beforeEach(() => {
});
describe("createApp", () => {
it.each([
["127.0.0.1", "127.0.0.1", "198.51.100.1", "198.51.100.1"],
["127.0.0.1", "::ffff:127.0.0.1", "198.51.100.1", "198.51.100.1"],
["10.0.0.0/8", "10.2.3.4", "198.51.100.1, 10.3.4.5", "198.51.100.1"],
["::1/128", "::1", "2001:db8::1", "2001:db8::1"],
["127.0.0.1", "127.0.0.1", "192.0.2.99, 198.51.100.1", "198.51.100.1"],
["127.0.0.1", "203.0.113.9", "198.51.100.1", "203.0.113.9"],
["127.0.0.1", "127.0.0.1", "invalid, 198.51.100.1", "127.0.0.1"],
])("resolves auth client through trusted %s from socket %s", async (proxy, peer, forwarded, expected) => {
mocks.trustedProxies.push(proxy);
const { createApp } = await import("./app");
const request = new Request("http://localhost/api/auth/sign-in/email", {
headers: { "x-forwarded-for": forwarded },
});
await createApp().fetch(request, transportEnv(peer));
expect(mocks.handleAuth).toHaveBeenCalledWith(request, expected);
});
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
const { createApp } = await import("./app");
const app = createApp();
+25 -4
View File
@@ -1,10 +1,11 @@
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
import type { Context } from "hono";
import { isIP } from "node:net";
import { BlockList, isIP } from "node:net";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Hono } from "hono";
import { compress } from "hono/compress";
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
import { env } from "@reactive-resume/env/server";
import { handleMcp } from "../mcp/handler";
import { handleOpenApi } from "../openapi/handler";
import {
@@ -26,10 +27,22 @@ import { handleResumePdfDownload } from "./resume-pdf";
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
const getTrustedClient = (context: Context<ServerEnvironment>, proxies: BlockList): string => {
try {
const address = getConnInfo(context).remote.address?.trim();
return address && isIP(address) ? address : "unknown";
if (!address || !isIP(address)) return "unknown";
const trusted = (ip: string) => proxies.check(ip, isIP(ip) === 4 ? "ipv4" : "ipv6");
if (!trusted(address)) return address;
const forwarded = context.req.header("x-forwarded-for");
if (!forwarded) return address;
const chain = forwarded.split(",").map((ip) => ip.trim());
if (chain.some((ip) => !isIP(ip))) return address;
let client = address;
for (const hop of chain.reverse()) {
if (!trusted(client)) break;
client = hop;
}
return client;
} catch {
return "unknown";
}
@@ -42,7 +55,15 @@ type AppOptions = {
export function createApp(options: AppOptions = {}) {
const app = new Hono<ServerEnvironment>();
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
const proxies = new BlockList();
for (const range of env.TRUSTED_PROXIES) {
const [address, prefix] = range.split("/");
if (!address) continue;
const family = isIP(address) === 4 ? "ipv4" : "ipv6";
if (prefix === undefined) proxies.addAddress(address, family);
else proxies.addSubnet(address, Number(prefix), family);
}
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c, proxies);
app.use("/auth/*", async (c, next) => {
await next();
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1416 -1383
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1111,7 +1111,7 @@ msgstr "Anyone who opens the public URL will need this password."
msgid "Anyone with the link will be able to view your resume."
msgstr "Anyone with the link will be able to view your resume."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Anything to remember about this job"
msgstr "Anything to remember about this job"
@@ -2270,7 +2270,7 @@ msgstr "Couldn't save the application."
msgid "Couldn't save the follow-up."
msgstr "Couldn't save the follow-up."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Couldn't save the notes."
msgstr "Couldn't save the notes."
@@ -2290,6 +2290,14 @@ msgstr "Couldn't save the tags."
msgid "Couldn't save your changes. Please try again."
msgstr "Couldn't save your changes. Please try again."
#: src/features/letters/store.ts
#: src/features/resume/share/history-tab.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
#: src/routes/builder/letter/$coverLetterId.tsx
msgid "Couldn't save your changes. Try again before continuing."
msgstr "Couldn't save your changes. Try again before continuing."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/detail/sent-documents.tsx
msgid "Couldn't save."
@@ -2854,6 +2862,11 @@ msgstr "Download application details, contacts, notes, and timeline history as C
msgid "Download CSV"
msgstr "Download CSV"
#: src/features/resume/share/download-tab.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
msgid "Download failed."
msgstr "Download failed."
#: src/features/resume/public/public-resume.tsx
#: src/routes/builder/$resumeId/-components/editor-bar.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
@@ -3007,6 +3020,10 @@ msgstr "Duration"
msgid "Dutch"
msgstr "Dutch"
#: src/features/assistant/conversation.tsx
msgid "Each send starts fresh with this message and selected files. Previous conversation history stays here."
msgstr "Each send starts fresh with this message and selected files. Previous conversation history stays here."
#: src/components/input/chip-input.tsx
#: src/features/applications/components/detail/next-step-card.tsx
#: src/features/resume/editor/write/basics-card.tsx
@@ -3592,6 +3609,10 @@ msgstr "Font Weight"
msgid "Font Weights"
msgstr "Font Weights"
#: src/libs/error-message.ts
msgid "Fonts could not be loaded. Retry the export."
msgstr "Fonts could not be loaded. Retry the export."
#: src/routes/builder/letter/-components/write-panel.tsx
msgid "For"
msgstr "For"
@@ -5481,8 +5502,8 @@ msgstr "Not verified yet. Resend the link"
msgid "Note"
msgstr "Note"
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-form-sheet.tsx
#: src/features/applications/components/application-notes.tsx
#: src/features/applications/components/import-applications-sheet.tsx
#: src/features/applications/components/interview-dialog.tsx
#: src/libs/resume/section.tsx
@@ -6126,6 +6147,10 @@ msgstr "Previewing {0} · click to apply"
msgid "Previous issue"
msgstr "Previous issue"
#: src/features/assistant/conversation.tsx
msgid "Previous messages, including document details, are also sent."
msgstr "Previous messages, including document details, are also sent."
#: src/features/applications/components/calendar-view.tsx
msgid "Previous month"
msgstr "Previous month"
@@ -6743,6 +6768,10 @@ msgstr "Retrieved {0}. Origin freshness is unknown unless reported."
msgid "Retry"
msgstr "Retry"
#: src/features/applications/components/application-notes.tsx
msgid "Retry save"
msgstr "Retry save"
#: src/features/resume/editor/save-status.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
msgid "Retry saving"
@@ -7517,6 +7546,10 @@ msgstr "Some glyphs have no meaning outside this file."
msgid "Some lines are stored out of reading order."
msgstr "Some lines are stored out of reading order."
#: src/libs/error-message.ts
msgid "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
msgstr "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
#: src/features/resume/editor/check/issues.ts
msgid "Some systems mishandle images, and photos are discouraged in some countries."
msgstr "Some systems mishandle images, and photos are discouraged in some countries."
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1416 -1383
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1111,7 +1111,7 @@ msgstr ""
msgid "Anyone with the link will be able to view your resume."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Anything to remember about this job"
msgstr ""
@@ -2270,7 +2270,7 @@ msgstr ""
msgid "Couldn't save the follow-up."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Couldn't save the notes."
msgstr ""
@@ -2290,6 +2290,14 @@ msgstr ""
msgid "Couldn't save your changes. Please try again."
msgstr ""
#: src/features/letters/store.ts
#: src/features/resume/share/history-tab.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
#: src/routes/builder/letter/$coverLetterId.tsx
msgid "Couldn't save your changes. Try again before continuing."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/detail/sent-documents.tsx
msgid "Couldn't save."
@@ -2854,6 +2862,11 @@ msgstr ""
msgid "Download CSV"
msgstr ""
#: src/features/resume/share/download-tab.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
msgid "Download failed."
msgstr ""
#: src/features/resume/public/public-resume.tsx
#: src/routes/builder/$resumeId/-components/editor-bar.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
@@ -3007,6 +3020,10 @@ msgstr ""
msgid "Dutch"
msgstr ""
#: src/features/assistant/conversation.tsx
msgid "Each send starts fresh with this message and selected files. Previous conversation history stays here."
msgstr ""
#: src/components/input/chip-input.tsx
#: src/features/applications/components/detail/next-step-card.tsx
#: src/features/resume/editor/write/basics-card.tsx
@@ -3592,6 +3609,10 @@ msgstr ""
msgid "Font Weights"
msgstr ""
#: src/libs/error-message.ts
msgid "Fonts could not be loaded. Retry the export."
msgstr ""
#: src/routes/builder/letter/-components/write-panel.tsx
msgid "For"
msgstr ""
@@ -5481,8 +5502,8 @@ msgstr ""
msgid "Note"
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-form-sheet.tsx
#: src/features/applications/components/application-notes.tsx
#: src/features/applications/components/import-applications-sheet.tsx
#: src/features/applications/components/interview-dialog.tsx
#: src/libs/resume/section.tsx
@@ -6126,6 +6147,10 @@ msgstr ""
msgid "Previous issue"
msgstr ""
#: src/features/assistant/conversation.tsx
msgid "Previous messages, including document details, are also sent."
msgstr ""
#: src/features/applications/components/calendar-view.tsx
msgid "Previous month"
msgstr ""
@@ -6743,6 +6768,10 @@ msgstr ""
msgid "Retry"
msgstr ""
#: src/features/applications/components/application-notes.tsx
msgid "Retry save"
msgstr ""
#: src/features/resume/editor/save-status.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
msgid "Retry saving"
@@ -7517,6 +7546,10 @@ msgstr ""
msgid "Some lines are stored out of reading order."
msgstr ""
#: src/libs/error-message.ts
msgid "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
msgstr ""
#: src/features/resume/editor/check/issues.ts
msgid "Some systems mishandle images, and photos are discouraged in some countries."
msgstr ""
+2 -2
View File
@@ -4,9 +4,9 @@ Third-party notices for PDF rendering
Reactive Resume renders PDFs with the Forme engine, which runs in the browser
as WebAssembly. Its copyright and license notice follows.
@formepdf/core 0.25.0
@formepdf/core 0.26.0
---------------------
Source: https://github.com/formepdf/forme
Source: https://github.com/danmolitor/forme
License: MIT
MIT License
@@ -5,7 +5,7 @@ import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useId, useRef, useState } from "react";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
@@ -24,13 +24,13 @@ import {
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { daysInStage } from "../next-step";
import { getClosedReasonLabel, getNextStage, getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions, useInvalidateApplications } from "../use-application-actions";
import { ApplicationNotes } from "./application-notes";
import { Activity } from "./detail/activity";
import { CloseDialog } from "./detail/close-dialog";
import { Contacts } from "./detail/contacts";
@@ -253,7 +253,7 @@ function Detail({ application, onEditDetails, onDeleted }: DetailProps) {
<SentDocuments application={application} disabled={remove.isPending} />
<Facts application={application} locale={i18n.locale} />
<Tags application={application} />
<Notes application={application} />
<ApplicationNotes application={application} />
<Activity application={application} onOpenInterview={(entry) => setInterview({ open: true, entry })} />
</div>
@@ -446,58 +446,6 @@ function Tags({ application }: { application: Application }) {
);
}
// Notes save a moment after typing stops.
const NOTES_SAVE_DELAY_MS = 800;
function Notes({ application }: { application: Application }) {
const id = useId();
const invalidate = useInvalidateApplications();
const [notes, setNotes] = useState(application.notes ?? "");
const saved = useRef(application.notes ?? "");
const { mutate } = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError: () => toast.add({ type: "error", description: t`Couldn't save the notes.` }),
});
useEffect(() => {
if (notes === saved.current) return;
const timeout = window.setTimeout(() => {
saved.current = notes;
mutate({ id: application.id, notes: notes.trim() ? notes : null });
}, NOTES_SAVE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [notes, application.id, mutate]);
// Closing the sheet mid-sentence still saves what was typed.
const latest = useRef(notes);
useEffect(
() => () => {
if (latest.current !== saved.current)
mutate({ id: application.id, notes: latest.current.trim() ? latest.current : null });
},
[application.id, mutate],
);
return (
<section className="grid gap-2">
<label htmlFor={id} className="text-xs font-semibold text-ink-3 uppercase">
<Trans>Notes</Trans>
</label>
<Textarea
id={id}
rows={3}
value={notes}
placeholder={t`Anything to remember about this job`}
onChange={(event) => {
latest.current = event.target.value;
setNotes(event.target.value);
}}
/>
</section>
);
}
type PostingDialogProps = { application: Application; open: boolean; onOpenChange: (open: boolean) => void };
type MarkAppliedDialogProps = { application: Application; onClose: () => void };
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApplicationNotes } from "./application-notes";
const mocks = vi.hoisted(() => ({ update: vi.fn(), invalidate: vi.fn() }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
applications: {
update: { mutationOptions: () => ({ mutationFn: mocks.update }) },
getById: { queryKey: ({ input }: { input: { id: string } }) => ["application", input.id] },
},
},
}));
vi.mock("../use-application-actions", () => ({ useInvalidateApplications: () => mocks.invalidate }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn() } }));
afterEach(() => vi.resetAllMocks());
function open(id: string, client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })) {
i18n.load("en", {});
i18n.activate("en");
const notes = client.getQueryData<{ notes: string }>(["application", id])?.notes ?? "Saved";
return render(
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<ApplicationNotes application={{ id, notes }} />
</I18nProvider>
</QueryClientProvider>,
);
}
it("retains a failed note across closing and reopening, then retries it", async () => {
mocks.update.mockRejectedValue(new Error("offline"));
const view = open("failed-notes");
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Keep this draft" } });
await waitFor(() => expect(mocks.update).toHaveBeenCalled(), { timeout: 2000 });
await act(async () => {
view.unmount();
});
mocks.update.mockResolvedValue({});
open("failed-notes");
expect((screen.getByRole("textbox", { name: "Notes" }) as HTMLTextAreaElement).value).toBe("Keep this draft");
await waitFor(
() =>
expect(mocks.update).toHaveBeenLastCalledWith(
{ id: "failed-notes", notes: "Keep this draft" },
expect.anything(),
),
{ timeout: 2000 },
);
});
it("keeps acknowledged notes in the cache when the sheet reopens before refetch", async () => {
mocks.update.mockResolvedValue({});
const client = new QueryClient();
client.setQueryData(["application", "acknowledged-notes"], { notes: "Saved" });
const view = open("acknowledged-notes", client);
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Acknowledged draft" } });
await waitFor(
() =>
expect(client.getQueryData(["application", "acknowledged-notes"])).toMatchObject({ notes: "Acknowledged draft" }),
{ timeout: 2000 },
);
view.unmount();
open("acknowledged-notes", client);
expect(screen.getByRole("textbox", { name: "Notes" })).toHaveValue("Acknowledged draft");
});
it("serializes note saves so a delayed response cannot overwrite newer text", async () => {
let resolve: (value: unknown) => void = vi.fn();
const first = new Promise<unknown>((accept) => {
resolve = accept;
});
mocks.update.mockReturnValueOnce(first).mockResolvedValue({});
const view = open("ordered-notes");
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "First" } });
await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1), { timeout: 2000 });
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Second" } });
await act(async () => {
view.unmount();
});
expect(mocks.update).toHaveBeenCalledTimes(1);
await act(async () => {
resolve({});
});
await waitFor(() =>
expect(mocks.update).toHaveBeenLastCalledWith({ id: "ordered-notes", notes: "Second" }, expect.anything()),
);
});
@@ -0,0 +1,92 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useState } from "react";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useInvalidateApplications } from "../use-application-actions";
import { orpc } from "@/libs/orpc/client";
type ApplicationNotesProps = { application: Pick<Application, "id" | "notes"> };
// Notes save a moment after typing stops.
const NOTES_SAVE_DELAY_MS = 800;
const drafts = new Map<string, string>();
const saves = new Map<string, Promise<void>>();
export function ApplicationNotes({ application }: ApplicationNotesProps) {
const id = useId();
const invalidate = useInvalidateApplications();
const queryClient = useQueryClient();
const [notes, setNotes] = useState(() => drafts.get(application.id) ?? application.notes ?? "");
const { mutateAsync, isError } = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError: () => toast.add({ type: "error", description: t`Couldn't save the notes.` }),
});
const save = useCallback(() => {
const pending: Promise<void> = (saves.get(application.id) ?? Promise.resolve())
.then(async () => {
const value = drafts.get(application.id);
if (value === undefined) return;
await mutateAsync({ id: application.id, notes: value.trim() ? value : null });
queryClient.setQueryData<Application>(
orpc.applications.getById.queryKey({ input: { id: application.id } }),
(current) => (current ? { ...current, notes: value.trim() ? value : null } : current),
);
if (drafts.get(application.id) === value) drafts.delete(application.id);
})
.catch(() => {}) // The mutation reports failure; retain the draft for retry.
.finally(() => {
if (saves.get(application.id) === pending) saves.delete(application.id);
});
saves.set(application.id, pending);
return pending;
}, [application.id, mutateAsync, queryClient]);
useEffect(() => {
if (!drafts.has(application.id)) return;
const timeout = window.setTimeout(() => {
void save();
}, NOTES_SAVE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [notes, application.id, save]);
// Closing the sheet mid-sentence still saves what was typed.
useEffect(
() => () => {
void save();
},
[save],
);
return (
<section className="grid gap-2">
<label htmlFor={id} className="text-xs font-semibold text-ink-3 uppercase">
<Trans>Notes</Trans>
</label>
<Textarea
id={id}
rows={3}
value={notes}
placeholder={t`Anything to remember about this job`}
onChange={(event) => {
drafts.set(application.id, event.target.value);
setNotes(event.target.value);
}}
/>
{isError && (
<button
type="button"
onClick={() => {
void save();
}}
className="justify-self-start text-sm underline"
>
<Trans>Retry save</Trans>
</button>
)}
</section>
);
}
@@ -281,17 +281,21 @@ function toProposals(
document: AssistantDocument,
): Proposal[] {
const output = part.output as ProposeEditsOutput | undefined;
return (output?.edits ?? []).map((edit) => ({
...edit,
target: {
return (output?.edits ?? []).map((edit) => {
const target = {
sectionId: edit.target.sectionId,
field: edit.target.field,
...(edit.target.itemId === undefined ? {} : { itemId: edit.target.itemId }),
},
location: document.locationOf(edit) ?? edit.location,
status: statuses.get(edit.id) ?? edit.status,
source: "assistant",
}));
...(edit.target.roleId === undefined ? {} : { roleId: edit.target.roleId }),
};
return {
...edit,
target,
location: document.locationOf({ ...edit, target }) ?? edit.location,
status: statuses.get(edit.id) ?? edit.status,
source: "assistant",
};
});
}
type MessageViewProps = {
@@ -788,7 +792,16 @@ export function Composer(props: ComposerProps) {
</button>
</div>
<p className="text-xs leading-4 text-ink-3">{disclosure}</p>
<p className="text-xs leading-4 text-ink-3">
{disclosure}{" "}
{!context.document || !context.posting ? (
<Trans>
Each send starts fresh with this message and selected files. Previous conversation history stays here.
</Trans>
) : (
<Trans>Previous messages, including document details, are also sent.</Trans>
)}
</p>
</div>
);
}
+15 -7
View File
@@ -1,4 +1,4 @@
import type { Proposal, ProposalState } from "@reactive-resume/resume/proposals";
import type { Passage, Proposal, ProposalState } from "@reactive-resume/resume/proposals";
import { t } from "@lingui/core/macro";
import { useQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
@@ -31,16 +31,24 @@ export type AssistantDocument = {
/** Applies proposals as one undo step, with Undo in the toast. */
accept: (proposals: readonly Proposal[]) => void;
/** Where a proposal lands, in the app's language, when its passage is still there. */
locationOf: (proposal: Pick<Proposal, "before">) => string | undefined;
locationOf: (proposal: Pick<Proposal, "before" | "target">) => string | undefined;
};
const bullet = (n: number) => t`bullet ${n}`;
const paragraph = (n: number) => t`paragraph ${n}`;
/** Finds the passage a proposal replaces, or the one an addition follows. */
function locate(passages: readonly { html: string; location: string }[], before: string) {
return passages.find((passage) => passage.html && (before === passage.html || before.startsWith(passage.html)))
?.location;
function locate(passages: readonly Passage[], { before, target }: Pick<Proposal, "before" | "target">) {
const matches = passages.filter(
(passage) =>
passage.target.sectionId === target.sectionId &&
passage.target.itemId === target.itemId &&
passage.target.roleId === target.roleId &&
passage.target.field === target.field &&
passage.html &&
(before === passage.html || before.startsWith(passage.html)),
);
return matches.length === 1 ? matches[0]?.location : undefined;
}
export function useResumeAssistantDocument(): AssistantDocument {
@@ -70,7 +78,7 @@ export function useResumeAssistantDocument(): AssistantDocument {
posting: application ? { id: application.id, company: application.company, role: application.role } : null,
stateOf: (proposal) => getProposalState(resume.data, proposal),
accept: acceptResumeProposals,
locationOf: (proposal) => locate(passages, proposal.before),
locationOf: (proposal) => locate(passages, proposal),
};
}, [resume.id, resume.name, resume.isLocked, resume.data, application]);
}
@@ -103,7 +111,7 @@ export function useLetterAssistantDocument(): AssistantDocument | null {
actionProps: { children: t`Undo`, onClick: () => edit({ content: before }) },
});
},
locationOf: (proposal) => locate(passages, proposal.before),
locationOf: (proposal) => locate(passages, proposal),
};
}, [letter, application]);
}
@@ -34,6 +34,16 @@ const letter: CoverLetter = {
const store = () => useLetterEditorStore.getState();
function deferred<T>() {
let resolve: (value: T) => void = vi.fn();
let reject: (error: unknown) => void = vi.fn();
const promise = new Promise<T>((accept, fail) => {
resolve = accept;
reject = fail;
});
return { promise, resolve, reject };
}
function* chunks(...parts: string[]) {
yield* parts;
}
@@ -44,6 +54,50 @@ beforeEach(() => {
});
describe("saving", () => {
it.each(["success", "failure"])("ignores an old session's %s while another letter is edited", async (result) => {
const request = deferred<CoverLetter>();
mocks.update.mockReturnValueOnce(request.promise).mockImplementation(async (input) => ({
...letter,
...input,
revision: 2,
}));
store().edit({ content: "<p>A's edit</p>" });
const saving = store().flush();
store().load({ ...letter, id: "B" });
store().edit({ content: "<p>B's edit</p>" });
if (result === "success") request.resolve({ ...letter, content: "<p>A's edit</p>", revision: 2 });
else request.reject(new ORPCError("CONFLICT"));
await saving;
expect(store().letter).toMatchObject({ id: "B", content: "<p>B's edit</p>" });
expect(store().pending).toEqual({ content: "<p>B's edit</p>" });
expect(store().status).toBe("saving");
await store().flush();
expect(mocks.update.mock.calls.map(([input]) => input.id)).toEqual(["letter", "B"]);
});
it("reports failed saves, retains the draft, and prevents a destructive change", async () => {
mocks.update.mockRejectedValue(new Error("offline"));
store().edit({ content: "<p>Unsaved</p>" });
expect(await store().flush()).toBe(false);
const restore = vi.fn();
await expect(store().change(restore)).rejects.toThrow();
expect(restore).not.toHaveBeenCalled();
expect(store().letter?.content).toBe("<p>Unsaved</p>");
expect(store().pending).toEqual({ content: "<p>Unsaved</p>" });
});
it("ignores a response after the same letter is reopened", async () => {
const request = deferred<CoverLetter>();
mocks.update.mockReturnValue(request.promise);
store().edit({ content: "<p>Old visit</p>" });
const saving = store().flush();
store().reset();
store().load(letter);
request.resolve({ ...letter, content: "<p>Old visit</p>", revision: 2 });
await saving;
expect(store().letter).toEqual(letter);
});
it("keeps what was typed while the server's copy comes back trimmed, and bumps the revision", async () => {
mocks.update.mockImplementation(async (input: { recipientName: string }) => ({
...letter,
+40 -15
View File
@@ -1,4 +1,5 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { t } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
import { create } from "zustand/react";
import { generateId } from "@reactive-resume/utils/string";
@@ -53,8 +54,8 @@ type LetterEditorStore = {
draft: LetterDraft;
load: (letter: CoverLetter) => void;
edit: (edits: LetterEdits) => void;
/** Saves what's pending now; resolves once everything typed so far is saved (or has failed). */
flush: () => Promise<void>;
/** Saves everything typed so far. False on failure; edits remain for retry. */
flush: () => Promise<boolean>;
/**
* Saves what's pending, then makes a change that returns the letter (a link, a template, a restore). Throws what
* the change throws, so the caller can say what went wrong.
@@ -78,7 +79,12 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
sessionId: generateId(),
draft: idleDraft,
load: (letter) => set({ letter, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft }),
load: (letter) => {
clearTimeout(timer);
drafting?.abort();
drafting = null;
set({ letter, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft });
},
edit: (edits) => {
set((state) =>
@@ -95,11 +101,14 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
},
flush: async () => {
const { sessionId } = get();
clearTimeout(timer);
if (inflight) await inflight;
if (get().sessionId !== sessionId) return false;
const { letter, pending, sessionId, status } = get();
if (!letter || !hasEdits(pending) || status === "conflict") return;
const { letter, pending, status } = get();
if (!letter || !hasEdits(pending) || status === "conflict") return status === "saved";
const current = (state: LetterEditorStore) => state.letter?.id === letter.id && state.sessionId === sessionId;
set({ pending: {}, status: "saving" });
inflight = (async () => {
@@ -112,13 +121,21 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
});
// What was typed stays as typed (the server trims and cleans what it stores), and so does anything typed
// since.
set((state) => ({
letter: applyEdits(saved, mergeEdits(pending, state.pending)),
status: hasEdits(state.pending) ? "saving" : "saved",
}));
set((state) =>
current(state)
? {
letter: applyEdits(saved, mergeEdits(pending, state.pending)),
status: hasEdits(state.pending) ? "saving" : "saved",
}
: state,
);
} catch (error) {
const conflict = error instanceof ORPCError && error.code === "CONFLICT";
set((state) => ({ pending: mergeEdits(pending, state.pending), status: conflict ? "conflict" : "error" }));
set((state) =>
current(state)
? { pending: mergeEdits(pending, state.pending), status: conflict ? "conflict" : "error" }
: state,
);
} finally {
inflight = null;
}
@@ -126,22 +143,30 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
await inflight;
// Typed while that save was on its way.
if (get().status === "saving" && hasEdits(get().pending)) await get().flush();
if (!current(get())) return false;
if (get().status === "saving" && hasEdits(get().pending)) return get().flush();
return get().status === "saved";
},
change: async (action) => {
await get().flush();
const { sessionId } = get();
if (!(await get().flush())) throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const { letter } = get();
if (!letter) throw new Error("No letter is open.");
if (!letter || get().sessionId !== sessionId) throw new Error("No letter is open.");
const next = await action(letter);
set((state) => ({ letter: applyEdits(next, state.pending) }));
set((state) =>
state.letter?.id === letter.id && state.sessionId === sessionId
? { letter: applyEdits(next, state.pending) }
: state,
);
return next;
},
reset: () => {
clearTimeout(timer);
drafting?.abort();
set({ letter: null, status: "saved", pending: {}, draft: idleDraft });
drafting = null;
set({ letter: null, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft });
},
}));
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { act, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEditorStore } from "../store";
import { ParserView } from "./parser-view";
const mocks = vi.hoisted(() => ({ resumeId: "A", extract: vi.fn(async (file: File) => file.text()) }));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (select: (resume: { id: string }) => unknown) => select({ id: mocks.resumeId }),
}));
vi.mock("./use-check", () => ({ useCheck: () => null }));
vi.mock("@/features/ats-checker/extract-client", () => ({ extractPdf: mocks.extract }));
vi.mock("@reactive-resume/resume/ats-pdf", () => ({
buildExtractedDocument: (text: string) => ({ pages: [], lines: [{ text }], charCount: text.length }),
buildResumeSemantics: () => ({
contact: { nameLine: "", locationLine: "", emails: [], phones: [], textUrls: [], annotationUrls: [] },
dates: [],
headings: [],
}),
}));
it("extracts each document and each reopened render independently", async () => {
i18n.load("en", {});
i18n.activate("en");
const client = new QueryClient();
const open = (id: string, text: string) => {
mocks.resumeId = id;
useEditorStore.getState().reset();
useEditorStore.getState().setRendered({ pageCount: 1, pageMap: undefined, file: new Blob([text]) });
};
const ui = (
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<ParserView />
</I18nProvider>
</QueryClientProvider>
);
open("A", "Resume Alpha");
const view = render(ui);
await screen.findByText("Resume Alpha");
view.unmount();
open("B", "Resume Beta");
const second = render(ui);
await screen.findByText("Resume Beta");
expect(screen.queryByText("Resume Alpha")).toBeNull();
second.unmount();
open("A", "Changed Alpha");
render(ui);
await screen.findByText("Changed Alpha");
expect(mocks.extract).toHaveBeenCalledTimes(3);
await act(async () => {
client.clear();
});
});
@@ -3,21 +3,19 @@ import type { AtsRuleCode } from "@reactive-resume/resume/ats";
import type { ExtractedDocument, ResumeSemantics } from "@reactive-resume/resume/ats-pdf";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore } from "../store";
import { useCheck } from "./use-check";
import { extractPdf } from "@/features/ats-checker/extract-client";
import { blobToPdfFile } from "@/features/ats-checker/run-ats-check";
import { useCurrentBuilderResumeSelector } from "@/features/resume/builder/draft";
type Parsed = { doc: ExtractedDocument; semantics: ResumeSemantics };
/** Reads the PDF on the page the way a parser does: its text layer in reading order, and what it recognises. */
async function parseRenderedPdf(): Promise<Parsed> {
const file = useEditorStore.getState().rendered.file;
if (!file) throw new Error("The page hasn't rendered yet.");
async function parseRenderedPdf(file: Blob): Promise<Parsed> {
// The operator pass (hidden text, images of text) feeds the full check, not this view, so it's skipped.
const engine = import("@reactive-resume/resume/ats-pdf");
engine.catch(() => {}); // Awaited below; see run-ats-check.ts.
@@ -62,12 +60,17 @@ function IssueChip({ issue }: { issue: CheckIssue }) {
* recognises and the lines behind open issues highlighted.
*/
export function ParserView() {
const version = useEditorStore((state) => state.rendered.version);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { file, version } = useEditorStore((state) => state.rendered);
const issues = useCheck()?.issues ?? [];
const { data, isError } = useQuery({
queryKey: ["check-parser-view", version],
queryFn: parseRenderedPdf,
placeholderData: keepPreviousData,
queryKey: ["check-parser-view", resumeId, version],
queryFn: () => {
if (!file) throw new Error("The page hasn't rendered yet.");
return parseRenderedPdf(file);
},
enabled: !!file,
placeholderData: (previous, query) => (query?.queryKey[1] === resumeId ? previous : undefined),
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60_000,
retry: false,
@@ -4,6 +4,7 @@ import { t } from "@lingui/core/macro";
import { produce } from "immer";
import {
applyProposal,
applyTo,
getProposalState,
readTarget,
splitBlock,
@@ -58,14 +59,9 @@ export function markProposals(data: ResumeData, proposals: readonly Proposal[]):
return produce(data, (draft) => {
for (const proposal of pending) {
const value = readTarget(draft, proposal.target);
if (value === undefined || !value.includes(proposal.before)) continue;
const marked = markChange(proposal.before, proposal.after);
writeTarget(
draft,
proposal.target,
value.replace(proposal.before, () => marked),
);
const next = applyTo(value, { before: proposal.before, after: marked });
if (next !== undefined) writeTarget(draft, proposal.target, next);
}
});
}
+2 -1
View File
@@ -218,5 +218,6 @@ export const useEditorStore = create<EditorStore>()((set) => ({
setWritingReview: (writingReview) => set({ writingReview }),
setExportCheck: (exportCheck) => set({ exportCheck }),
setExportReportOpen: (exportReportOpen) => set({ exportReportOpen }),
reset: () => set(initialState),
reset: () =>
set((state) => ({ ...initialState, rendered: { ...initialState.rendered, version: state.rendered.version + 1 } })),
}));
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { RichTextEditor } from "./rich-text-editor";
const mocks = vi.hoisted(() => ({ mobile: false }));
vi.mock("@reactive-resume/ui/hooks/use-mobile", () => ({ useIsMobile: () => mocks.mobile }));
vi.mock("@reactive-resume/ui/hooks/use-keyboard-inset", () => ({ useKeyboardInset: () => 0 }));
vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () => ({
useHasUsableAiProvider: () => ({ hasUsableProvider: false }),
}));
vi.mock("@/hooks/use-confirm", () => ({ usePrompt: () => vi.fn() }));
vi.mock("./improve", () => ({ lineAtCaret: () => null, ImprovePanel: () => null }));
it.each([false, true])(
"retains formatting controls while keyboard focus enters the toolbar (mobile=%s)",
async (mobile) => {
mocks.mobile = mobile;
i18n.load("en", {});
i18n.activate("en");
render(
<I18nProvider i18n={i18n}>
<RichTextEditor label="Summary" value="<p>Example</p>" onChange={vi.fn()} />
<button type="button">Outside</button>
</I18nProvider>,
);
const editor = await screen.findByRole("textbox", { name: "Summary" });
await act(async () => {
editor.focus();
});
const bold = screen.getByRole("button", { name: "Bold" });
if (mobile)
await act(async () => {
bold.focus();
});
else await userEvent.tab();
expect(document.activeElement).toBe(bold);
expect(screen.getByRole("toolbar", { name: "Formatting" })).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("button", { name: "Bold" }).getAttribute("aria-pressed")).toBe("true");
await act(async () => {
screen.getByRole("button", { name: "Outside" }).focus();
});
expect(screen.queryByRole("toolbar")).toBeNull();
},
);
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useKeyboardInset } from "@reactive-resume/ui/hooks/use-keyboard-inset";
@@ -110,6 +110,7 @@ export function RichTextEditor({
heightClassName = "max-h-[360px] min-h-[88px]",
}: RichTextEditorProps) {
const [focused, setFocused] = useState(false);
const toolbar = useRef<HTMLDivElement>(null);
const [improving, setImproving] = useState<ImproveLine | null>(null);
const actions = useToolbarActions();
const ai = useHasUsableAiProvider();
@@ -138,8 +139,6 @@ export function RichTextEditor({
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
const state = useEditorState({
@@ -216,6 +215,11 @@ export function RichTextEditor({
return (
<div
onFocus={() => setFocused(true)}
onBlur={(event) => {
const next = event.relatedTarget;
if (!event.currentTarget.contains(next) && !toolbar.current?.contains(next)) setFocused(false);
}}
className={cn(
"rounded-lg border border-line-2 bg-raised transition-[border-color,box-shadow] duration-quick",
editing && "border-accent shadow-[0_0_0_3px_var(--accent-soft)]",
@@ -229,6 +233,7 @@ export function RichTextEditor({
mobile &&
createPortal(
<div
ref={toolbar}
role="toolbar"
aria-label={t`Formatting`}
style={{ bottom: keyboardInset }}
@@ -240,6 +245,7 @@ export function RichTextEditor({
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setImproving(null);
setFocused(false);
editor?.commands.blur();
}}
className="flex h-11 shrink-0 items-center rounded-md px-3 text-sm font-semibold text-accent-text"
@@ -262,7 +268,12 @@ export function RichTextEditor({
{/* Under the text, so focusing the field never moves the line you clicked. */}
{editing && !readOnlyTable && !mobile && (
<div role="toolbar" aria-label={t`Formatting`} className="flex gap-0.5 border-t border-line px-1.5 py-1">
<div
ref={toolbar}
role="toolbar"
aria-label={t`Formatting`}
className="flex gap-0.5 border-t border-line px-1.5 py-1"
>
{toolbarButtons}
</div>
)}
@@ -11,6 +11,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { createResumePdfBlob } from "./pdf-document";
import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf";
import { getReadableErrorMessage } from "@/libs/error-message";
import { client } from "@/libs/orpc/client";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
@@ -156,8 +157,11 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
// Statistics are best effort and must not delay or fail a completed browser download.
void client.resume.statistics.recordDownload(exportOptions.publicResumePdf.publicResume).catch(() => undefined);
}
} catch {
toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` });
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not generate the PDF. Please try again.`),
});
}
setIsExporting(false);
toast.close(toastId);
@@ -173,10 +177,10 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
: createResumePdfBlob(resume.data);
try {
printPdf(await makeBlob());
} catch {
} catch (error) {
toast.add({
type: "error",
description: t`Could not prepare your resume for printing. Please try again.`,
description: getReadableErrorMessage(error, t`Could not prepare your resume for printing. Please try again.`),
});
}
setIsExporting(false);
@@ -2,6 +2,7 @@
import { render, screen } from "@testing-library/react";
import { expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeReflow } from "./resume-reflow";
@@ -14,3 +15,37 @@ it("shows the built-in Summary content in public readable view", () => {
render(<ResumeReflow data={data} />);
expect(screen.getByText("Built-in summary remains visible.")).toBeDefined();
});
it("includes plain custom fields and reference phone numbers", () => {
const data = structuredClone(defaultResumeData);
data.basics.customFields = [{ id: "authorization", icon: "", text: "Authorized to work", link: "" }];
data.sections.references.items = [
referenceItemSchema.parse({
id: "ref",
hidden: false,
name: "Dana",
position: "Lead",
phone: "+49 123 456",
description: "",
website: { url: "", label: "" },
}),
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["references"], sidebar: [] }];
render(<ResumeReflow data={data} />);
expect(screen.getByText("Authorized to work").closest("a")).toBeNull();
expect(screen.getByRole("link", { name: "+49 123 456" }).getAttribute("href")).toBe("tel:+49123456");
});
it.each([
["ar", "ltr", "rtl"],
["en-US", "rtl", "ltr"],
] as const)("sets %s document direction independently of interface direction", (locale, parent, direction) => {
const data = structuredClone(defaultResumeData);
data.metadata.page.locale = locale;
const { container } = render(
<div dir={parent}>
<ResumeReflow data={data} />
</div>,
);
expect(container.querySelector(`[lang="${locale}"]`)?.getAttribute("dir")).toBe(direction);
});
@@ -3,6 +3,7 @@ import type { IconName } from "@reactive-resume/ui/components/icon";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { Icon } from "@reactive-resume/ui/components/icon";
import { contrastOnWhite } from "@reactive-resume/utils/color";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { reflowOrder } from "./reflow";
import { RichText } from "./rich-text";
@@ -63,6 +64,7 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
const url = typeof entry.url === "string" ? entry.url : website?.url;
const keywords = Array.isArray(entry.keywords) ? (entry.keywords as string[]).filter(Boolean) : [];
const html = text(entry, "content") || text(entry, "description");
const phone = type === "references" ? text(entry, "phone") : "";
return (
<article className="grid gap-0.5">
@@ -76,6 +78,11 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
</a>
)}
{keywords.length > 0 && <p className="text-[14px] text-[#555]">{keywords.join(", ")}</p>}
{phone && (
<a className="w-fit text-[14px] underline" href={`tel:${phone.replace(/\s+/g, "")}`}>
{phone}
</a>
)}
{html && <RichText html={html} className={cn(RICH, "mt-1")} />}
{roles.map((role) => (
<div key={role.id} className="mt-1.5 grid gap-0.5">
@@ -88,7 +95,7 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
);
}
type ContactPill = { icon: IconName; label: string; href: string };
type ContactPill = { icon: IconName; label: string; href?: string };
function contactPills(basics: ResumeData["basics"]): ContactPill[] {
const pills: ContactPill[] = [];
@@ -101,8 +108,12 @@ function contactPills(basics: ResumeData["basics"]): ContactPill[] {
href: basics.website.url,
});
for (const field of basics.customFields)
if (field.text && /^(https?:|mailto:|tel:)/i.test(field.link))
pills.push({ icon: "link", label: field.text, href: field.link });
if (field.text)
pills.push({
icon: "link",
label: field.text,
...(/^(https?:|mailto:|tel:)/i.test(field.link) ? { href: field.link } : {}),
});
return pills;
}
@@ -123,6 +134,7 @@ export function ResumeReflow({ data }: ResumeReflowProps) {
return (
<div
lang={data.metadata.page.locale}
dir={isRTL(data.metadata.page.locale) ? "rtl" : "ltr"}
className="grid gap-6 bg-white px-5 py-6 text-[15px] leading-[1.5] text-[#1a1a1a]"
style={{ fontFamily: `"${font}", ui-sans-serif, system-ui, sans-serif` }}
>
@@ -132,18 +144,21 @@ export function ResumeReflow({ data }: ResumeReflowProps) {
<p className="text-[#555]">{[basics.headline, basics.location].filter(Boolean).join(" · ")}</p>
)}
<ul className="flex flex-wrap gap-2">
{contactPills(basics).map((pill) => (
<li key={pill.href}>
<a
href={pill.href}
className="flex h-9 items-center gap-1.5 rounded-full border border-[#ddd] px-3 text-[14px]"
{...(pill.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer nofollow" } : {})}
>
<Icon name={pill.icon} size={18} />
<span className="max-w-[16rem] truncate">{pill.label}</span>
</a>
</li>
))}
{contactPills(basics).map((pill, index) => {
const Tag = pill.href ? "a" : "span";
return (
<li key={`${index}:${pill.label}`}>
<Tag
href={pill.href}
className="flex h-9 items-center gap-1.5 rounded-full border border-[#ddd] px-3 text-[14px]"
{...(pill.href?.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer nofollow" } : {})}
>
<Icon name={pill.icon} size={18} />
<span className="max-w-[16rem] truncate">{pill.label}</span>
</Tag>
</li>
);
})}
</ul>
</header>
@@ -21,6 +21,7 @@ import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
import { getReadableErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS } from "@/libs/motion";
import { client } from "@/libs/orpc/client";
@@ -271,8 +272,9 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
toast.add({ description: t`Downloaded ${file}` });
}
setState("done");
} catch {
} catch (error) {
setState("error");
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Download failed.`) });
}
};
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEditorStore } from "../editor/store";
import { HistoryTab } from "./history-tab";
const mocks = vi.hoisted(() => ({
save: vi.fn(),
create: vi.fn(),
restore: vi.fn(),
replace: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/features/resume/builder/draft", () => ({
savePendingChanges: mocks.save,
useCurrentResume: () => ({ id: "resume", isLocked: false }),
useResumeStore: { getState: () => ({ replaceResumeFromServer: mocks.replace }) },
}));
vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn(), usePrompt: () => vi.fn() }));
vi.mock("@/libs/error-message", () => ({ getResumeErrorMessage: (error: Error) => error.message }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: mocks.toast } }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
listVersions: {
queryKey: () => ["versions"],
queryOptions: () => ({
queryKey: ["versions"],
queryFn: async () => [{ id: "version", kind: "named", name: "Checkpoint", createdAt: new Date() }],
}),
},
getById: { queryKey: () => ["resume"] },
createVersion: { call: mocks.create },
restoreVersion: { call: mocks.restore },
},
},
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.save.mockResolvedValue(false);
useEditorStore.getState().reset();
i18n.load("en", {});
i18n.activate("en");
});
it.each(["name", "restore"])("stops History %s after the save barrier fails", async (action) => {
if (action === "restore") useEditorStore.getState().setHistoryVersion("version");
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<HistoryTab />
</I18nProvider>
</QueryClientProvider>,
);
if (action === "name") {
fireEvent.change(screen.getByRole("textbox", { name: "Name this version" }), {
target: { value: "Unsaved draft" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
} else fireEvent.click(await screen.findByRole("button", { name: "Restore this version" }));
await waitFor(() =>
expect(mocks.toast).toHaveBeenCalledWith({
type: "error",
description: "Couldn't save your changes. Try again before continuing.",
}),
);
expect(mocks.save).toHaveBeenCalledWith("resume");
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.restore).not.toHaveBeenCalled();
expect(mocks.replace).not.toHaveBeenCalled();
if (action === "name")
expect((screen.getByRole("textbox", { name: "Name this version" }) as HTMLInputElement).value).toBe(
"Unsaved draft",
);
else expect(useEditorStore.getState().historyVersionId).toBe("version");
client.clear();
});
@@ -66,12 +66,14 @@ function useResumeHistory(): HistorySource {
nowDetail: t`The resume as it is`,
errorMessage: getResumeErrorMessage,
save: async (name) => {
await savePendingChanges(resume.id);
if (!(await savePendingChanges(resume.id)))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await orpc.resume.createVersion.call({ resumeId: resume.id, name });
void refresh();
},
restore: async (versionId) => {
await savePendingChanges(resume.id);
if (!(await savePendingChanges(resume.id)))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const restored = await orpc.resume.restoreVersion.call({ resumeId: resume.id, versionId });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
@@ -38,7 +38,6 @@ export function WebAccessSection() {
setEditing(false);
test.reset();
void queryClient.invalidateQueries({ queryKey: orpc.webAccess.status.key() });
void queryClient.invalidateQueries({ queryKey: orpc.firecrawl.status.key() });
};
const save = useMutation(orpc.webAccess.save.mutationOptions({ onSuccess: refresh }));
const remove = useMutation(orpc.webAccess.delete.mutationOptions({ onSuccess: refresh }));
+4
View File
@@ -2,6 +2,10 @@ import { t } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
export function getReadableErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.cause === "pdf-text-loss") {
if (error.message.startsWith("Fonts could not be loaded:")) return t`Fonts could not be loaded. Retry the export.`;
return t`Some PDF text could not be rendered. Choose a font containing these characters, then retry the export.`;
}
if (error instanceof ORPCError && error.code === "BAD_REQUEST") {
const data: unknown = error.data;
if (typeof data === "object" && data !== null && "issues" in data && Array.isArray(data.issues)) {
@@ -1,7 +1,9 @@
import { t } from "@lingui/core/macro";
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect, useBlocker } from "@tanstack/react-router";
import { useEffect } from "react";
import z from "zod";
import { toast } from "@reactive-resume/ui/components/toast";
import { LetterShell } from "./-components/letter-shell";
import { useLetterEditorStore } from "@/features/letters/store";
import { orpc } from "@/libs/orpc/client";
@@ -45,6 +47,23 @@ function RouteComponent() {
const queryClient = useQueryClient();
const { data: letter } = useSuspenseQuery(orpc.coverLetters.getById.queryOptions({ input: { id: coverLetterId } }));
const loaded = useLetterEditorStore((state) => state.letter?.id === coverLetterId);
useBlocker({
shouldBlockFn: async ({ next }) => {
if ("coverLetterId" in next.params && next.params.coverLetterId === coverLetterId) return false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const saved = await Promise.race([
useLetterEditorStore.getState().flush(),
new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), 10_000);
}),
]);
clearTimeout(timeout);
if (!saved)
toast.add({ type: "error", description: t`Couldn't save your changes. Try again before continuing.` });
return !saved;
},
enableBeforeUnload: false,
});
// The editor owns the letter from here; later refetches don't overwrite what's being typed.
useEffect(() => {
@@ -54,11 +73,14 @@ function RouteComponent() {
// Leaving saves what's pending, then lets lists (Documents, Applications) catch up.
useEffect(
() => () => {
const sessionId = useLetterEditorStore.getState().sessionId;
void useLetterEditorStore
.getState()
.flush()
.finally(() => {
if (useLetterEditorStore.getState().letter?.id === coverLetterId) useLetterEditorStore.getState().reset();
.then((saved) => {
if (!saved) return;
const state = useLetterEditorStore.getState();
if (state.letter?.id === coverLetterId && state.sessionId === sessionId) state.reset();
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.key() });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.key() });
@@ -27,7 +27,7 @@ import { useLetterEditorStore } from "@/features/letters/store";
import { BackLink, DocumentMenuTrigger, DrawerControls } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { usePrompt } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { getOrpcErrorMessage, getReadableErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS } from "@/libs/motion";
import { client, orpc } from "@/libs/orpc/client";
@@ -106,8 +106,11 @@ export function useDownloadLetter() {
try {
const blob = await createLetterFile(letter, words, "pdf");
downloadWithAnchor(blob, `${letterFileName(letter, words)}.pdf`);
} catch {
toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` });
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not generate the PDF. Please try again.`),
});
}
setBusy(false);
toast.close(toastId);
@@ -182,7 +185,8 @@ function LetterMenu() {
const duplicate = async () => {
try {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const copy = await client.coverLetters.duplicate({ id });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ description: t`Duplicated` });
@@ -205,7 +209,8 @@ function LetterMenu() {
// Undoable, so it doesn't ask first: the letter waits in Trash for 30 days.
const trash = async () => {
try {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await client.documents.trash(ref);
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void navigate({ to: "/dashboard" });
@@ -24,7 +24,7 @@ import {
} from "@/features/resume/share/download-tab";
import { HistoryTimeline } from "@/features/resume/share/history-tab";
import { useClosingValue } from "@/hooks/use-closing-value";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { getOrpcErrorMessage, getReadableErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
/**
@@ -146,8 +146,9 @@ function LetterDownloadTab() {
toast.add({ description: t`Downloaded ${file}` });
}
setState("done");
} catch {
} catch (error) {
setState("error");
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Download failed.`) });
}
};
@@ -201,7 +202,8 @@ function useLetterHistory(open: boolean): HistorySource {
nowDetail: t`The letter as it is`,
errorMessage: (error) => getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
save: async (name) => {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await client.coverLetters.createVersion({ id, name });
void refresh();
},
+2 -2
View File
@@ -8,7 +8,7 @@ rss: true
## Breaking changes & upgrade actions
- **Stop v5 before upgrading the database.** Seven migrations introduce document Trash, richer version history, application outcomes, structured letters and document-bound conversations, then remove `application.archived` and `resume_version.label`. Back up the database and uploads, stop every v5 instance, and deploy v6 together. A mixed v5/v6 rolling upgrade is unsupported. Follow [Upgrading to v6](/self-hosting/upgrading-to-v6), including its rollback procedure. [cbc76b03b](https://github.com/reactive-resume/reactive-resume/commit/cbc76b03b)
- **Stop v5 before upgrading the database.** Ten migrations introduce document Trash, richer version history, application outcomes, structured letters, document-bound conversations and web-access credentials, then remove `application.archived`, `resume_version.label` and the retired Firecrawl credential table. Back up the database and uploads, stop every v5 instance, and deploy v6 together. A mixed v5/v6 rolling upgrade is unsupported. Follow [Upgrading to v6](/self-hosting/upgrading-to-v6), including its rollback procedure. [cbc76b03b](https://github.com/reactive-resume/reactive-resume/commit/cbc76b03b)
- **Cover letters become independent documents.** The migration saves every embedded letter, including hidden letters, as a separate letter linked to its resume's sender details and design, then removes the letter sections from the resume. Open migrated letters in **Documents**. Older imports, stale tabs, API writes and restored versions also move embedded letters into documents without creating duplicates for unchanged content. [250650953](https://github.com/reactive-resume/reactive-resume/commit/250650953)
- **Convert old visual style rules manually.** PDFs now use Semantic CSS exclusively. Stored rules from the old visual style editor need the bundled `apps/server/dist/migrate-legacy-styles.mjs` script; startup does not convert them. Run its dry run first, then `--apply --backup <file>` on persistent storage. Unconverted documents use their template defaults. See [the conversion steps](/self-hosting/upgrading-to-v6#convert-styles-from-the-old-style-editor). Old JSON files imported through the browser are converted during import. [a325232a0](https://github.com/reactive-resume/reactive-resume/commit/a325232a0), [92459122c](https://github.com/reactive-resume/reactive-resume/commit/92459122c)
- **Write structured dates in API clients.** Dated entries use `dates`, with `start`, `end`, `present` and preserved `raw` text. Saves regenerate `period` or `date` from that structure, so editing the display text alone is overwritten. Update clients to write `dates` and set `metadata.page.dateFormat` where needed. Inputs without structured dates still derive them from their text. [bda01febd](https://github.com/reactive-resume/reactive-resume/commit/bda01febd), [cbc76b03b](https://github.com/reactive-resume/reactive-resume/commit/cbc76b03b)
@@ -110,7 +110,7 @@ unreleased changes outside that commit audit.
## Assistant & AI providers
- **Enable shared AI and Firecrawl services for all users.** Self-hosters can set `AI_PROVIDER`, `AI_MODEL`, `AI_API_KEY` and an optional `AI_BASE_URL`, plus `FIRECRAWL_API_URL` and/or `FIRECRAWL_API_KEY`. Each globally configured integration takes precedence over personal credentials and hides their settings controls; saved personal keys remain available after shared configuration is removed. Shared credentials stay on the server and need no `ENCRYPTION_SECRET`; encrypted personal AI and Firecrawl Cloud keys remain supported when it is set. See [Job search and AI](/self-hosting/job-search-and-ai).
- **Enable shared AI and web access for all users.** Self-hosters can set `AI_PROVIDER`, `AI_MODEL`, `AI_API_KEY` and an optional `AI_BASE_URL`, plus `WEB_ACCESS_PROVIDER`, `WEB_ACCESS_API_KEY` and an optional Firecrawl `WEB_ACCESS_API_URL`. Each globally configured integration takes precedence over personal credentials and hides their settings controls; saved personal keys remain available after shared configuration is removed. Shared credentials stay on the server and need no `ENCRYPTION_SECRET`; encrypted personal AI and web-access keys remain supported when it is set. See [Job search and AI](/self-hosting/job-search-and-ai).
<Frame caption="The assistant works beside the document it is helping with">
<img
+1 -1
View File
@@ -5,7 +5,7 @@ description: "Shared search and reading contracts, provider ownership, safety an
Applications and the Assistant share `searchWeb` and `readPage` in
`packages/api/src/features/web-access/`. Keep generic retrieval there; JobPosting parsing and the `job posting`
query suffix belong in Applications. Firecrawl uses its installed SDK; Tavily and Exa use Node fetch with validated
query suffix belong in Applications. All three providers use the shared bounded JSON transport with validated
responses. Select one adapter by the provider discriminant, without a plugin registry or paid-provider fan-out.
## Shared contract
+24 -24
View File
@@ -53,7 +53,7 @@ No separate search, scraping, extraction, and research setup is required. AI con
3. Preserve the source link and saved description used for preparation; show retrieval information for fetched content. Refreshing a posting must not silently replace the evidence behind existing documents.
4. Connect saving to existing resume selection, copy-for-job, manual editing, and assistant review flows. Keep letters optional.
5. Make external application handoff and confirmed submission distinct, using existing sent-document history.
6. Support Firecrawl, Tavily, and Exa for both search and page reading through shared application-owned functions. Preserve existing Firecrawl credentials, custom server endpoints, URL protections, and built-in fallback.
6. Support Firecrawl, Tavily, and Exa for both search and page reading through shared application-owned functions. Preserve custom server endpoints, URL protections, and built-in fallback.
7. Connect those same functions to the existing AI SDK assistant and support verified native-search configurations for OpenAI, Anthropic, and Gemini. Complete capability selection, source display, cancellation, and error handling in the same release.
8. Replace Firecrawl-specific settings and feature gates with one optional web connection and capability-based availability. Retain optional search without promoting broad job discovery as a new product promise.
@@ -63,15 +63,15 @@ These changes should reuse existing application records and pipeline stages. Pre
### Provider choice
| External provider | Search | Read a supplied URL | Reason to include |
| ----------------- | ------------------- | ------------------- | --------------------------------------------------------------------------------- |
| Firecrawl | Existing Search API | Existing Scrape API | Preserve current users, custom endpoints, and self-hosted deployments. |
| Tavily | Search API | Extract API | One alternative connection covers both operations. |
| Exa | Search API | Contents API | A third independent backend covers both operations without another setup concept. |
| External provider | Search | Read a supplied URL | Reason to include |
| ----------------- | ---------- | ------------------- | --------------------------------------------------------------------------------- |
| Firecrawl | Search API | Scrape API | Support custom endpoints and self-hosted deployments. |
| Tavily | Search API | Extract API | One alternative connection covers both operations. |
| Exa | Search API | Contents API | A third independent backend covers both operations without another setup concept. |
These are three actual external search providers; native LLM search is additional support and does not substitute for the three-provider requirement. Each can be used independently. Supporting all three does not mean configuring or calling all three.
Keep the installed Firecrawl SDK. Implement the two small Tavily and Exa HTTP adapters with Node's built-in fetch and Zod response validation. The application needs their search/read endpoints, not their entire SDKs. Use the installed AI SDK's `tool()` to expose shared functions to the assistant; adding provider-specific AI SDK tool packages would duplicate the configuration and normalization needed by ordinary UI requests.
Implement all three HTTP adapters with the existing bounded JSON transport and Zod response validation. The application needs their search/read endpoints, not their entire SDKs. Use the installed AI SDK's `tool()` to expose shared functions to the assistant; adding provider-specific AI SDK tool packages would duplicate the configuration and normalization needed by ordinary UI requests.
For Tavily imports, request Markdown extraction without query-based chunk reranking and inspect per-URL failures even on HTTP 200. For Exa imports, request page text rather than highlights or summaries and use the documented freshness controls; the older `livecrawl` option is deprecated. Search requests should avoid full-page extraction and generated answers. Fetch a selected result only when it is needed. [Tavily Search](https://docs.tavily.com/documentation/api-reference/endpoint/search), [Tavily Extract](https://docs.tavily.com/documentation/api-reference/endpoint/extract), [Exa Search](https://exa.ai/docs/reference/search), [Exa Contents](https://exa.ai/docs/reference/get-contents).
@@ -132,19 +132,19 @@ Native-search support is limited to verified model/endpoint combinations and doc
Store one selected external connection per user: provider plus encrypted API key. Reuse existing credential encryption. Do not create a table or settings card per provider, store several inactive keys, or require separate defaults for search and reading.
Add a generic `web_access_credentials` table and backfill existing Firecrawl ciphertext as provider `firecrawl` in a generated migration, without decrypting it or asking users to enter keys again. The new service becomes the sole runtime source. Retain the old table only as a compatibility/rollback artifact, not another active configuration source; replacement or deletion of a user's connection must also remove any obsolete legacy key for that user. Review SQL and verify backup/restore before deployment. Application rollback after configuration changes must account for the new credentials rather than assuming old binaries understand them.
Use the generic `web_access_credentials` table as the sole credential source. The pre-release Firecrawl API has no users or compatibility promise; remove its handlers, environment aliases and retired credential table. Keep historical migrations and generate a migration that drops the obsolete table. Review SQL and verify backup/restore before deployment.
Expose generic status, save, delete, and test procedures under `/integrations/web-access`. Keep the existing Firecrawl endpoints as narrow compatibility handlers. Legacy reads report Firecrawl availability only when Firecrawl is selected. Legacy writes must not overwrite or delete an active Tavily/Exa connection; return a conflict directing clients to the generic endpoint. Keep all existing server-managed and encryption-precondition checks.
Expose status, save, delete, and test procedures under `/integrations/web-access`. Keep all server-managed and encryption-precondition checks.
Server configuration uses `WEB_ACCESS_PROVIDER`, `WEB_ACCESS_API_KEY`, and an optional `WEB_ACCESS_API_URL` for a custom Firecrawl service. Tavily and Exa use their official fixed endpoints. Preserve `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` as aliases when no generic configuration is supplied. Explicit generic configuration wins; partial or inconsistent generic configuration fails validation rather than silently falling back. A self-hosted Firecrawl endpoint may remain keyless as today. Personal connections use cloud endpoints and do not expose arbitrary base URLs.
Server configuration uses `WEB_ACCESS_PROVIDER`, `WEB_ACCESS_API_KEY`, and an optional `WEB_ACCESS_API_URL` for a custom Firecrawl service. Tavily and Exa use their official fixed endpoints. Partial or inconsistent configuration fails validation. A self-hosted Firecrawl endpoint may be keyless. Personal connections use cloud endpoints and do not expose arbitrary base URLs.
Resolver order is explicit server configuration, legacy server Firecrawl configuration, personal connection, then built-in reading only. Server-managed configuration continues to disable personal credential changes. Resolve credentials for each request/run; never keep a singleton client containing a user's key.
Resolver order is server configuration, personal connection, then built-in reading only. Server-managed configuration disables personal credential changes. Resolve credentials for each request/run; never keep a singleton client containing a user's key.
Status should communicate available capabilities, selected provider, managed/personal ownership, and whether keys can be changed, without returning secrets. A user-triggered connection test probes search and reading independently and reports actual results, including unsupported self-hosted search or quota failures. The test must bypass reader fallback so a successful built-in fetch cannot falsely validate a broken provider. Do not retest or incur external calls every time settings renders.
### UI and operational behavior
Replace the Firecrawl settings section with a single provider selector inside the optional connection form: Firecrawl, Tavily, Exa. Use generic availability to gate Applications search. Existing Firecrawl users see their connection already selected after migration. New users see a working built-in reader and an optional connection action.
Use a single provider selector inside the optional connection form: Firecrawl, Tavily, Exa. Use generic availability to gate Applications search. New users see a working built-in reader and an optional connection action.
Complete the Save/Applied, source review, truncation recovery, preparation, and submission changes from the product scope in the same release. Every provider must pass through the same workflow and receive the same error treatment.
@@ -156,16 +156,16 @@ Log provider, operation, duration, safe failure category, and fallback outcome.
Work in the following dependency order and release only when the complete end state passes. These are implementation tasks, not separate product increments.
| Order | Work | Main existing owners |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Fix the shared contracts, selection rules, and final provider set before editing consumers. | `packages/api/src/features/web-access/`, `packages/ai/src/tools/agent-tool-contracts.ts` |
| 2 | Build all three adapters and move the safe built-in reader; keep job normalization in Applications. | `packages/api/src/features/applications/posting.ts`, new web-access feature |
| 3 | Add one credential migration, resolver, generic integration router, and legacy compatibility handlers. | `packages/db/src/schema/firecrawl.ts`, `migrations/`, `packages/api/src/features/firecrawl/`, `packages/api/src/routers/index.ts` |
| 4 | Wire Applications and the assistant to the shared service, including native search, tool contracts, source rendering, cancellation, and errors. | `applications/ai.ts`, `agent/tools.ts`, `agent/service.ts`, `ai/capabilities.ts`, `ai/service.ts`, web assistant feature |
| 5 | Finish the one-connection settings UI and agreed job-saving/preparation workflow against the final contracts. | Web settings, Applications, document-copy/detail features, application schema/DTOs |
| 6 | Update environment validation, docs, API spec, translations, and deployment checks; run the whole acceptance matrix. | `packages/env/src/server.ts`, `.env.example`, `turbo.json`, self-hosting/AI guides, `docs/spec.json`, Lingui catalogs |
| Order | Work | Main existing owners |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Fix the shared contracts, selection rules, and final provider set before editing consumers. | `packages/api/src/features/web-access/`, `packages/ai/src/tools/agent-tool-contracts.ts` |
| 2 | Build all three adapters and move the safe built-in reader; keep job normalization in Applications. | `packages/api/src/features/applications/posting.ts`, new web-access feature |
| 3 | Use one credential table, resolver and generic integration router; retire pre-release compatibility code. | `packages/db/src/schema/web-access.ts`, `migrations/`, `packages/api/src/features/web-access/`, `packages/api/src/routers/index.ts` |
| 4 | Wire Applications and the assistant to the shared service, including native search, tool contracts, source rendering, cancellation, and errors. | `applications/ai.ts`, `agent/tools.ts`, `agent/service.ts`, `ai/capabilities.ts`, `ai/service.ts`, web assistant feature |
| 5 | Finish the one-connection settings UI and agreed job-saving/preparation workflow against the final contracts. | Web settings, Applications, document-copy/detail features, application schema/DTOs |
| 6 | Update environment validation, docs, API spec, translations, and deployment checks; run the whole acceptance matrix. | `packages/env/src/server.ts`, `.env.example`, `turbo.json`, self-hosting/AI guides, `docs/spec.json`, Lingui catalogs |
This order avoids rewriting the UI or assistant once per provider. Keep all runtime-specific code in its current owning packages. Check server bundling and package export boundaries; retaining the Firecrawl SDK and using built-in fetch avoids adding two more vendor SDK bundles.
This order avoids rewriting the UI or assistant once per provider. Keep all runtime-specific code in its current owning packages. Check server bundling and package export boundaries; the bounded native transport needs no vendor SDK bundles.
## Acceptance conditions
@@ -179,7 +179,7 @@ This order avoids rewriting the UI or assistant once per provider. Keep all runt
- Existing Firecrawl search, rendered reading, custom server URL support, and direct-reader fallback keep working.
- Firecrawl, Tavily, and Exa each work independently for Applications search/import and assistant search/read. Users need only the selected provider's credentials.
- Native assistant search works for verified OpenAI, Anthropic, and Gemini configurations without an external web connection. Unsupported combinations fail clearly or use an explicitly configured external connection.
- Explicit provider selection is respected, existing credentials migrate, and legacy API calls cannot overwrite another provider's configuration.
- Explicit provider selection is respected; each account has one encrypted connection, with changes disabled under server configuration.
- All web tools show correct progress, errors, and validated sources after streaming and after reloading a conversation. Cancellation stops outstanding retrieval.
- Core behavior works on Docker and Vercel without adding required services or containers.
@@ -191,7 +191,7 @@ Use the existing Vitest and Playwright setup. Add focused behavior checks; no ne
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Adapter contract | Table-driven mocked HTTP checks for all three providers: result mapping, per-URL errors, auth/quota failures, malformed/empty results, timeout, response limit, and abort. |
| URL safety and fallback | Existing private-host/DNS/redirect checks remain effective; failure falls back within budget, while unsafe URLs and abort do not. |
| Credentials and migration | Existing Firecrawl rows survive; users cannot access each other's keys; server precedence, partial env errors, deletion, and legacy-route conflicts behave as specified. |
| Credentials and migration | The retired table is removed; users cannot access each other's keys; server precedence, partial env errors, connection replacement and deletion behave as specified. |
| AI SDK and history | Tool selection, compatible native/custom combinations, capability-aware instructions, sources, stop behavior, and old conversation rendering/replay. |
| Product journey | Save/paste/manual preparation without services; all three provider choices through the same UI; failed enrichment retains the posting; Saved stays distinct from Applied; submitted document versions remain correct. |
| Deployment | Affected typechecks/tests, non-mutating lint, package boundaries, production build, and existing serverless artifact checks. Verify Docker and Vercel environment handling. |
@@ -215,7 +215,7 @@ Keep public-URL validation, redirect and network protections, credential encrypt
- Email/calendar synchronization and automatic submission.
- A general plugin platform or custom connector protocol.
Document the adapter contract and contribution checks as part of this release. Evaluate completeness, reliability, latency, deployment behavior, and cost per usable import; published feature lists alone do not establish a best hosted default. Existing installs keep Firecrawl, while new installs start with the built-in reader and no commercial connection.
Document the adapter contract and contribution checks as part of this release. Evaluate completeness, reliability, latency, deployment behavior, and cost per usable import; published feature lists alone do not establish a best hosted default. New installs start with the built-in reader and no commercial connection.
## Implementation verification
+1 -3
View File
@@ -189,8 +189,6 @@ for Tavily/Exa, or a custom URL for those providers stops startup instead of sil
| `WEB_ACCESS_PROVIDER` | unset | One selected provider: `firecrawl`, `tavily` or `exa`. |
| `WEB_ACCESS_API_KEY` | unset | Shared provider key. Required except for Firecrawl with an explicit custom URL. |
| `WEB_ACCESS_API_URL` | Firecrawl Cloud | Optional Firecrawl base URL without `/v2`, e.g. `http://localhost:3102`. Private service URLs are operator-controlled. Tavily and Exa always use their official endpoints. |
| `FIRECRAWL_API_URL` | unset | Legacy Firecrawl URL alias, used only when no `WEB_ACCESS_*` configuration is supplied. A URL alone permits keyless self-hosted Firecrawl. |
| `FIRECRAWL_API_KEY` | unset | Legacy Firecrawl key alias. A key alone selects Firecrawl Cloud when no generic configuration is supplied. |
Connections enable Applications keyword search and assistant web tools. URL import uses the selected reader, then
falls back to the built-in reader on recoverable failures. Saving pasted text and manual preparation work without
@@ -201,7 +199,7 @@ until the user reviews and saves it.
does not call external services. Quota and authentication failures leave manual workflows available.
See [Job search and AI](/self-hosting/job-search-and-ai) for connection setup, Firecrawl deployment, optional SearXNG
search and migration/rollback guidance. Public page URLs remain protected against private destinations, redirects
search and backup guidance. Public page URLs remain protected against private destinations, redirects
and DNS rebinding. A remote reader must enforce these protections internally too. Search queries and selected URLs
go to the chosen service; resume data and AI keys do not.
+8 -15
View File
@@ -43,8 +43,8 @@ You can mix these choices, such as shared web access with personal AI keys. See
## 2. Connect Firecrawl
Follow the official [Firecrawl self-hosting guide](https://docs.firecrawl.dev/contributing/self-host) to deploy its
API and supporting services. Use the release and Compose files recommended there; the npm SDK included in Reactive
Resume is a client and does not run the service.
API and supporting services. Use the release and Compose files recommended there. Reactive Resume connects to
that service over HTTP.
Add the connection to **Reactive Resume's** environment:
@@ -64,8 +64,7 @@ in Reactive Resume must point to public HTTPS destinations.
For Firecrawl Cloud, use its [quickstart](https://docs.firecrawl.dev/introduction) to get a key, set only
`WEB_ACCESS_PROVIDER="firecrawl"` and `WEB_ACCESS_API_KEY`, and leave `WEB_ACCESS_API_URL` unset. Requests then go to
`https://api.firecrawl.dev`. Tavily and Exa use fixed official endpoints and require a key. Supplying their own API URL
is rejected. Existing `FIRECRAWL_API_KEY`/`FIRECRAWL_API_URL` variables remain aliases when every generic variable is
unset; partial generic configuration never falls back to those aliases.
is rejected. Incomplete web-access configuration fails startup.
### Container networking and local development
@@ -171,18 +170,12 @@ and restart or redeploy it. Keep keys in server configuration or secret storage,
Reading failures can fall back to the built-in reader, so a successful URL import alone does not prove the external
service was used. Review the displayed posting source and use the independent connection test.
## Existing credentials and rollback
## Credential backups
The migration copies Firecrawl ciphertext unchanged into `web_access_credentials` with provider `firecrawl`; users
keep their selected connection without entering keys again. The generic table is the sole runtime source. The old
`firecrawl_credentials` table remains as a rollback artifact, and replacing or removing a connection removes that
user's obsolete legacy key. Legacy API endpoints cannot overwrite or delete a selected Tavily/Exa connection.
Back up PostgreSQL and preserve `ENCRYPTION_SECRET` before deployment. Restore the backup into a disposable database
and verify existing keys resolve before upgrading the live installation. If rolling back after connection changes,
restore the matching pre-upgrade backup or explicitly migrate current Firecrawl credentials back to the legacy table;
old binaries cannot read Tavily/Exa credentials. Retaining the old table alone does not restore keys removed during
connection changes.
Personal connections store encrypted keys in `web_access_credentials`. Preserve `ENCRYPTION_SECRET` with the
PostgreSQL backup so restored keys can be decrypted. Verify restored connections in a disposable installation
before upgrading the live installation. The retired Firecrawl API, environment aliases and credential table are
removed; all providers use `/integrations/web-access` and the `WEB_ACCESS_*` variables.
## Opt-in live connection check
+24 -22
View File
@@ -9,11 +9,11 @@ This page is for installations already on v5. Coming from v4? Follow [Migrating
## What to expect
- **Migrations run on startup**, as in v5. Seven new migrations add columns and tables, move data, and drop two legacy columns. You don't run anything by hand.
- **v5 can't run against the upgraded database.** The last migrations remove columns that v5 reads, so stop every v5 instance before the first v6 instance starts. There's no mixed v5 and v6 rolling update.
- **Migrations run on startup**, as in v5. One v6 migration adds columns and tables, moves data, and drops two legacy columns. You don't run anything by hand.
- **v5 can't run against the upgraded database.** The v6 migration removes columns that v5 reads, so stop every v5 instance before the first v6 instance starts. There's no mixed v5 and v6 rolling update.
- **Cover letters leave resumes.** Every letter stored inside a resume becomes a saved letter of its own.
- **Old style-editor styles need a one-time manual conversion**, with a script you run yourself. Nothing converts them automatically.
- **No new environment variables.** Everything you set for v5.3 keeps working. `REDIS_URL` is now optional for the assistant.
- **New optional environment variables** configure server AI and web access. Existing v5.3 settings keep working. Reverse proxies need `TRUSTED_PROXIES` to preserve per-visitor authentication limits; `REDIS_URL` is now optional for the assistant outside Vercel.
- **Vercel projects must switch to the Services framework preset** before they deploy v6.
## Before you upgrade
@@ -131,21 +131,21 @@ PostgreSQL 18 changes the default data layout. Changing the image tag alone does
## What the migrations change
Every migration runs once, in order, under an advisory lock. Three of them ship a `rollback.sql` next to `migration.sql` in the repository's `migrations/` folder.
Existing v5 migrations stay unchanged. The unreleased v6 changes are consolidated in `20261001042749_v6_release`, generated from the current schema with the v5 data conversions included once. It runs under an advisory lock and ships one `rollback.sql` alongside `migration.sql`.
| Migration | What it does | Rollback script |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `20260928171330_resume_version_kinds` | Adds a `kind`, `name`, and session to resume versions (filled in from the old labels) and a `resume_slug_redirect` table, so a renamed public link keeps working for 30 days. | No (additive) |
| `20260928175116_documents_trash_and_links` | Adds Trash (`trashed_at`) to resumes and letters, tags and locking to letters, and a link from a resume to the application it was made for. | No (additive) |
| `20260928193941_applications_closed_and_sent` | Adds the **Closed** stage with a reason, and what was sent with an application. Turns `rejected` applications into closed (not selected) and archived ones into closed. Gives an application its letter when exactly one letter was written for it. | Yes |
| `20260928201742_letters_structured_and_versions` | Adds structured recipient fields, links to a resume's details and design, and version history (`cover_letter_version`) for letters. Existing letters keep their free-form layout and their own copied details. | No (additive) |
| `20260928211634_assistant_documents_and_outcomes` | Lets assistant conversations belong to a letter and count proposed and accepted edits. | No (additive) |
| `20260929063245_contract_redesign_legacy_fields` | Closes anything still archived or rejected, then drops `application.archived` and `resume_version.label`. After this, v5 no longer works against the database. | Yes |
| `20260929071322_letters_leave_resumes` | Saves every cover letter stored inside a resume as a letter of its own, then removes the cover-letter sections from resumes and their page layouts. | Yes |
| Area | What changes |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Resume versions | Adds `kind`, `name`, and session fields, fills kinds from old labels, then removes `label`. |
| Public links | Adds `resume_slug_redirect`, so renamed public links keep working for 30 days. |
| Documents | Adds Trash to resumes and letters, tags and locking to letters, and links from resumes to applications. |
| Applications | Adds the Closed stage and reason, sent document versions and Check score, requirements, and posting provenance. Converts rejected and archived applications once, updates their timeline, links unambiguous letters, then removes `archived`. |
| Letters | Adds structured recipient fields, live links to resume details and design, and version history. Saves embedded resume letters as separate documents and removes their sections from resumes and page layouts. |
| Assistant | Adds conversations about letters and counts of proposed and accepted edits. |
| Web access | Creates `web_access_credentials` directly for encrypted per-user connections. |
### Cover letters leaving resumes
In v5, a resume could hold cover letters as a section. The last migration moves each of them, hidden ones included, into a saved letter:
In v5, a resume could hold cover letters as a section. The v6 migration moves each of them, hidden ones included, into a saved letter:
- The letter is named after the resume and the section, for example "Product Designer — Cover letter".
- It's linked to the resume, and takes its sender details and design from it, so it looks as it did inside the resume.
@@ -224,8 +224,12 @@ See [Using the MCP server](/guides/using-the-mcp-server) and [Managing applicati
## Environment variables
v6 adds no environment variables and removes none. Two behaviors changed:
Existing variables remain supported. New settings are optional unless your deployment uses the corresponding feature:
- `AI_PROVIDER`, `AI_MODEL`, `AI_API_KEY`, and `AI_BASE_URL` configure a server AI provider. Set provider, model, and key together; Ollama may omit the key. `openai-compatible` also requires a base URL. Configuring this shared provider disables personal AI providers. Leave these unset to continue using user-configured providers.
- `WEB_ACCESS_PROVIDER`, `WEB_ACCESS_API_KEY`, and `WEB_ACCESS_API_URL` configure server web access. Providers are Firecrawl, Tavily, and Exa. Set provider and key together; only Firecrawl accepts a custom API URL, which may be keyless.
- `TRUSTED_PROXIES` lists comma-separated proxy IP addresses or CIDRs. Behind nginx, Caddy, or Traefik, include the immediate proxy and any trusted intermediate hops. The proxy must append or replace `X-Forwarded-For` with the actual client address. Leave this unset for direct connections. Never include public client networks: forwarded headers are trusted only through configured proxy addresses.
- `FLAG_DISABLE_API_RATE_LIMIT` now disables all API and authentication limits, including PDF exports and AI requests. Keep it `false` on public installations.
- `REDIS_URL` is optional for the assistant. Without Redis, replies can't resume after a page reload, and **Stop** reaches only a reply running on the same server. `ENCRYPTION_SECRET` is still required for AI providers and the assistant. Vercel still requires Redis.
- The web build now also produces `apps/web/dist-prerender` (localized marketing pages). The Docker image includes it. If you build and deploy the server yourself, copy it next to `apps/web/dist`; without it, those pages render in the browser instead.
@@ -244,23 +248,21 @@ Restoring the backup you took before the upgrade is the safest way back, and the
If you must keep data written since the upgrade, you can undo the migrations that v5 can't live with, by hand, before starting v5:
1. Stop every v6 instance.
2. Run the rollback scripts with `psql`, newest first:
2. Run the consolidated rollback script with `psql`:
```bash
psql "$DATABASE_URL" -f migrations/20260929071322_letters_leave_resumes/rollback.sql
psql "$DATABASE_URL" -f migrations/20260929063245_contract_redesign_legacy_fields/rollback.sql
psql "$DATABASE_URL" -f migrations/20260928193941_applications_closed_and_sent/rollback.sql
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/20261001042749_v6_release/rollback.sql
```
The first puts letters back into the resumes they came from (one section per resume per run; run it again for a resume that had several). The second restores `application.archived` and `resume_version.label`. The third turns closed applications back into `rejected` or archived.
The script puts letters back into the resumes they came from (one section per resume per run; run it again for a resume that had several), restores `application.archived` and `resume_version.label`, and turns closed applications back into `rejected` or archived. These steps run in one transaction.
3. Start v5.
Columns and tables added by v6 stay; v5 ignores them. The letters saved from resumes stay too, so in v5 each one shows both inside its resume and in the cover letter library. Documents in Trash show up again in v5, because v5 doesn't know about Trash. If you converted old styles, the converted stylesheets stay, and `--restore` with your backup file puts the originals back.
<Warning>
The rollback scripts don't change the migration ledger, so the v6 migrations stay recorded as applied. Upgrading the
same database to v6 again won't re-run them. Take a fresh backup and ask in [GitHub
The rollback script doesn't change the migration ledger, so the v6 migration stays recorded as applied. Upgrading the
same database to v6 again won't re-run it. Take a fresh backup and ask in [GitHub
Discussions](https://github.com/reactive-resume/reactive-resume/discussions) before you try.
</Warning>
-469
View File
@@ -20265,475 +20265,6 @@
}
}
},
"/integrations/firecrawl": {
"get": {
"operationId": "getFirecrawlStatus",
"summary": "Get Firecrawl availability",
"tags": [
"Integrations"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"managed": {
"type": "boolean"
},
"configured": {
"type": "boolean"
},
"canSave": {
"type": "boolean"
}
},
"required": [
"managed",
"configured",
"canSave"
]
}
}
}
}
}
},
"put": {
"operationId": "saveFirecrawlKey",
"summary": "Save a personal Firecrawl Cloud key",
"tags": [
"Integrations"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1,
"maxLength": 2000
}
},
"required": [
"apiKey"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"not": {}
},
{
"not": {}
}
]
}
}
}
},
"403": {
"description": "403",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "FORBIDDEN"
},
"status": {
"const": 403
},
"message": {
"type": "string",
"default": "Firecrawl is managed by the server."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
},
"409": {
"description": "409",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "CONFLICT"
},
"status": {
"const": 409
},
"message": {
"type": "string",
"default": "Manage the selected provider through /integrations/web-access."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
},
"412": {
"description": "412",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "PRECONDITION_FAILED"
},
"status": {
"const": 412
},
"message": {
"type": "string",
"default": "Credential encryption is not configured."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
}
},
"delete": {
"operationId": "deleteFirecrawlKey",
"summary": "Delete a personal Firecrawl Cloud key",
"tags": [
"Integrations"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"not": {}
},
{
"not": {}
}
]
}
}
}
},
"403": {
"description": "403",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "FORBIDDEN"
},
"status": {
"const": 403
},
"message": {
"type": "string",
"default": "Firecrawl is managed by the server."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
},
"409": {
"description": "409",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "CONFLICT"
},
"status": {
"const": 409
},
"message": {
"type": "string",
"default": "Manage the selected provider through /integrations/web-access."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
},
"412": {
"description": "412",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "PRECONDITION_FAILED"
},
"status": {
"const": 412
},
"message": {
"type": "string",
"default": "Credential encryption is not configured."
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
}
}
},
"/resumes/tags": {
"get": {
"operationId": "listResumeTags",
-1
View File
@@ -48,7 +48,6 @@
"fast-json-patch",
"fast-png",
"fflate",
"firecrawl",
"ioredis",
"jose",
"jsonrepair",
@@ -1,19 +0,0 @@
CREATE TABLE "resume_slug_redirect" (
"id" text PRIMARY KEY,
"slug" text NOT NULL,
"resume_id" text NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "resume_slug_redirect_user_id_slug_unique" UNIQUE("user_id","slug")
);
--> statement-breakpoint
DROP INDEX "resume_user_id_index";--> statement-breakpoint
ALTER TABLE "resume_version" ADD COLUMN "kind" text DEFAULT 'auto' NOT NULL;--> statement-breakpoint
UPDATE "resume_version" SET "kind" = CASE "label" WHEN 'Imported' THEN 'import' WHEN 'AI edit' THEN 'ai' WHEN 'Before restore' THEN 'before-restore' WHEN 'Restored version' THEN 'restored' ELSE 'auto' END;--> statement-breakpoint
ALTER TABLE "resume_version" ADD COLUMN "name" text;--> statement-breakpoint
ALTER TABLE "resume_version" ADD COLUMN "session_id" text;--> statement-breakpoint
CREATE INDEX "resume_slug_redirect_resume_id_index" ON "resume_slug_redirect" ("resume_id");--> statement-breakpoint
CREATE UNIQUE INDEX "resume_version_session_unique" ON "resume_version" ("resume_id","session_id") WHERE "kind" = 'auto';--> statement-breakpoint
ALTER TABLE "resume_slug_redirect" ADD CONSTRAINT "resume_slug_redirect_resume_id_resume_id_fkey" FOREIGN KEY ("resume_id") REFERENCES "resume"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "resume_slug_redirect" ADD CONSTRAINT "resume_slug_redirect_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
File diff suppressed because it is too large Load Diff
@@ -1,7 +0,0 @@
ALTER TABLE "cover_letter" ADD COLUMN "tags" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD COLUMN "is_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD COLUMN "trashed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "application_id" text;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "trashed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "auto_name" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "resume" ADD CONSTRAINT "resume_application_id_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE SET NULL;
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
ALTER TABLE "application" ADD COLUMN "closed_reason" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "cover_letter_id" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "sent_resume_version_id" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "sent_check_score" smallint;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "requirements" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_cover_letter_id_cover_letter_id_fkey" FOREIGN KEY ("cover_letter_id") REFERENCES "cover_letter"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_sent_resume_version_id_resume_version_id_fkey" FOREIGN KEY ("sent_resume_version_id") REFERENCES "resume_version"("id") ON DELETE SET NULL;--> statement-breakpoint
-- The closed stage replaces `rejected` (closed, not selected) and the `archived` flag (closed, no reason).
-- `archived` keeps its value so older app versions still hide these rows. rollback.sql reverses this.
UPDATE "application" SET "status" = 'closed', "closed_reason" = 'not-selected' WHERE "status" = 'rejected';--> statement-breakpoint
UPDATE "application" SET "status" = 'closed' WHERE "archived" = true AND "status" <> 'closed';--> statement-breakpoint
UPDATE "application" SET "activity" = (
SELECT jsonb_agg(
CASE WHEN entry->>'type' = 'stage' AND entry->>'stage' = 'rejected'
THEN jsonb_set(entry, '{stage}', '"closed"')
ELSE entry
END
ORDER BY position
)
FROM jsonb_array_elements("activity") WITH ORDINALITY AS items(entry, position)
)
WHERE "activity" @> '[{"type": "stage", "stage": "rejected"}]';--> statement-breakpoint
-- A letter written for exactly one application becomes that application's letter.
UPDATE "application" SET "cover_letter_id" = letters."id"
FROM (
SELECT "source_application_id", min("id") AS "id"
FROM "cover_letter"
WHERE "source_application_id" IS NOT NULL
GROUP BY "source_application_id"
HAVING count(*) = 1
) AS letters
WHERE "application"."id" = letters."source_application_id" AND "application"."cover_letter_id" IS NULL;
@@ -1,26 +0,0 @@
-- Reverses the closed stage for app versions before 6.0, which only know `rejected` and `archived`.
-- Run it by hand before downgrading; the added columns can stay, older versions ignore them.
UPDATE "application" SET "activity" = (
SELECT jsonb_agg(
CASE WHEN entry->>'type' = 'stage' AND entry->>'stage' = 'closed'
THEN jsonb_set(entry, '{stage}', '"rejected"')
ELSE entry
END
ORDER BY position
)
FROM jsonb_array_elements("activity") WITH ORDINALITY AS items(entry, position)
)
WHERE "activity" @> '[{"type": "stage", "stage": "closed"}]';
-- Closed as not selected was `rejected`.
UPDATE "application" SET "status" = 'rejected' WHERE "status" = 'closed' AND "closed_reason" = 'not-selected';
-- Any other closed application is archived at the last stage it reached before closing.
UPDATE "application" SET "archived" = true, "status" = coalesce((
SELECT entry->>'stage'
FROM jsonb_array_elements("activity") AS items(entry)
WHERE entry->>'type' = 'stage' AND entry->>'stage' NOT IN ('closed', 'rejected')
ORDER BY (entry->>'at')::timestamptz DESC
LIMIT 1
), 'saved')
WHERE "status" = 'closed';

Some files were not shown because too many files have changed in this diff Show More