mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
fix: finalize v6 migrations and document workflows
This commit is contained in:
+3
-3
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1416
-1383
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1416
-1383
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user