Shaheer Sarfaraz 7514aa1b28
Add RxResume v4/v5 dual support (#230)
* feat(settings): add rxresume mode and v5 api key settings

* feat(server): add mode-aware rxresume adapter with auto v5-first selection

* refactor(server): route settings profile and pdf generation through rxresume adapter

* feat(api): support rxresume v4/v5 in onboarding and settings routes with ok/meta responses

* feat(client): add rxresume mode selector and v5 api key setup flow

* docs: document rxresume auto mode with v5-first self-hosted setup

* test: verify dual-mode rxresume support and ci parity checks

* comments

* services folder

* correct types for v5

* tests and docs fix

* Fix RxResume auto fallback and route API consistency

* warning for both being set

* simpler response

* onboarding component improvements, v5 check still not working

* fix list resume endpoint...

* fix api endpoints to latest v5 docs

* don't show the entire project field on v5

* remove auto entirely

* formatting

* ci green

* v5 has a different resume schema

* remove redundant check

* remove requirement that only one must be specified

* consolidate sections

* base resume can be v4 or v5

* saving now works

* status indicator

* actually render some pills

* reason for failure

* fix apikey verification

* dedupe isValidatingMode

* reefactoor

* simplification?

* refactor?

* ci passing

* remove auto from docs

* tailoring is schema dependent

* skills object tighter

* remove redundant text

* fix lint

* mode
2026-02-25 02:26:15 +00:00

439 lines
12 KiB
TypeScript

import { createId } from "@paralleldrive/cuid2";
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types";
import { stripHtmlTags } from "@shared/utils/string";
import {
getResumeSchemaValidationMessage,
safeParseResumeDataForMode,
} from "./schema";
type RecordLike = Record<string, unknown>;
export type TailoredSkillsInput =
| Array<{ name: string; keywords: string[] }>
| string
| null
| undefined;
export type TailorChunkInput = {
headline?: string | null;
summary?: string | null;
skills?: TailoredSkillsInput;
};
export type ResumeProjectSelectionItem = ResumeProjectCatalogItem & {
summaryText: string;
};
export function cloneResumeData<T>(data: T): T {
return JSON.parse(JSON.stringify(data)) as T;
}
export function validateAndParseResumeDataForMode(
mode: RxResumeMode,
data: unknown,
):
| { ok: true; mode: RxResumeMode; data: RecordLike }
| { ok: false; mode: RxResumeMode; message: string } {
const result = safeParseResumeDataForMode(mode, data);
if (!result.success) {
return {
ok: false,
mode,
message: getResumeSchemaValidationMessage(result.error),
};
}
if (
!result.data ||
typeof result.data !== "object" ||
Array.isArray(result.data)
) {
return {
ok: false,
mode,
message:
"Resume schema validation failed: root payload must be an object.",
};
}
return { ok: true, mode, data: result.data as RecordLike };
}
export function inferRxResumeModeFromData(data: unknown): RxResumeMode | null {
const v5 = safeParseResumeDataForMode("v5", data);
if (v5.success) return "v5";
const v4 = safeParseResumeDataForMode("v4", data);
if (v4.success) return "v4";
return null;
}
function asRecord(value: unknown): RecordLike | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as RecordLike)
: null;
}
function asArray(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null;
}
function parseTailoredSkills(
skills: TailoredSkillsInput,
): Array<RecordLike> | null {
if (!skills) return null;
const parsed = Array.isArray(skills)
? skills
: typeof skills === "string"
? (JSON.parse(skills) as unknown)
: null;
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(item) => item && typeof item === "object",
) as RecordLike[];
}
export function applyTailoredHeadline(
mode: RxResumeMode,
resumeData: RecordLike,
headline?: string | null,
): void {
if (!headline) return;
const basics = asRecord(resumeData.basics);
if (!basics) return;
basics.headline = headline;
// Preserve current behavior for legacy consumers/templates that use label.
basics.label = headline;
if (mode === "v5") return;
}
export function applyTailoredSummary(
mode: RxResumeMode,
resumeData: RecordLike,
summary?: string | null,
): void {
if (!summary) return;
if (mode === "v5") {
const topSummary = asRecord(resumeData.summary);
if (topSummary) {
if (
typeof topSummary.content === "string" ||
topSummary.content === undefined
) {
topSummary.content = summary;
return;
}
if (
typeof topSummary.value === "string" ||
topSummary.value === undefined
) {
topSummary.value = summary;
return;
}
}
}
const sections = asRecord(resumeData.sections);
const summarySection = asRecord(sections?.summary);
if (summarySection) {
summarySection.content = summary;
return;
}
const basics = asRecord(resumeData.basics);
if (basics) basics.summary = summary;
}
function sanitizeV4SkillsSection(resumeData: RecordLike): void {
const sections = asRecord(resumeData.sections);
const skillsSection = asRecord(sections?.skills);
const items = asArray(skillsSection?.items);
if (!skillsSection || !items) return;
skillsSection.items = items.map((raw) => {
const skill = asRecord(raw) ?? {};
return {
...skill,
id: typeof skill.id === "string" && skill.id ? skill.id : createId(),
visible: typeof skill.visible === "boolean" ? skill.visible : true,
description:
typeof skill.description === "string" ? skill.description : "",
level: typeof skill.level === "number" ? skill.level : 1,
keywords: Array.isArray(skill.keywords)
? skill.keywords.filter((k) => typeof k === "string")
: [],
};
});
}
function applyTailoredSkillsV4(
resumeData: RecordLike,
skills: Array<RecordLike>,
): void {
sanitizeV4SkillsSection(resumeData);
const sections = asRecord(resumeData.sections);
const skillsSection = asRecord(sections?.skills);
if (!skillsSection) return;
const existingItems = asArray(skillsSection.items) ?? [];
const existing = existingItems
.map((item) => asRecord(item))
.filter((item): item is RecordLike => Boolean(item));
skillsSection.items = skills.map((newSkill) => {
const match = existing.find((item) => item.name === newSkill.name);
return {
id:
(typeof newSkill.id === "string" && newSkill.id) ||
(match && typeof match.id === "string" ? match.id : "") ||
createId(),
visible:
typeof newSkill.visible === "boolean"
? newSkill.visible
: typeof match?.visible === "boolean"
? match.visible
: true,
name:
(typeof newSkill.name === "string" ? newSkill.name : "") ||
(typeof match?.name === "string" ? match.name : ""),
description:
typeof newSkill.description === "string"
? newSkill.description
: typeof match?.description === "string"
? match.description
: "",
level:
typeof newSkill.level === "number"
? newSkill.level
: typeof match?.level === "number"
? match.level
: 0,
keywords: Array.isArray(newSkill.keywords)
? newSkill.keywords.filter((k) => typeof k === "string")
: Array.isArray(match?.keywords)
? match.keywords.filter((k) => typeof k === "string")
: [],
};
});
}
function applyTailoredSkillsV5(
resumeData: RecordLike,
skills: Array<RecordLike>,
): void {
const sections = asRecord(resumeData.sections);
const skillsSection = asRecord(sections?.skills);
const existingItems = asArray(skillsSection?.items);
if (!skillsSection || !existingItems) return;
const existing = existingItems
.map((item) => asRecord(item))
.filter((item): item is RecordLike => Boolean(item));
const template = existing[0] ?? null;
if (!template) return;
skillsSection.items = skills.map((newSkill) => {
const match =
existing.find((item) => item.name === newSkill.name) ?? template;
const next: RecordLike = { ...match };
if ("id" in next) {
next.id =
(typeof newSkill.id === "string" && newSkill.id) ||
(typeof match.id === "string" ? match.id : "") ||
createId();
}
if ("name" in next) {
next.name =
(typeof newSkill.name === "string" ? newSkill.name : "") ||
(typeof match.name === "string" ? match.name : "");
}
if ("keywords" in next) {
next.keywords = Array.isArray(newSkill.keywords)
? newSkill.keywords.filter((k) => typeof k === "string")
: Array.isArray(match.keywords)
? match.keywords.filter((k) => typeof k === "string")
: [];
}
// Only patch optional fields when the instance already uses them.
if ("description" in next) {
next.description =
typeof newSkill.description === "string"
? newSkill.description
: typeof match.description === "string"
? match.description
: "";
}
if ("proficiency" in next) {
next.proficiency =
typeof newSkill.proficiency === "string"
? newSkill.proficiency
: typeof newSkill.description === "string"
? newSkill.description
: typeof match.proficiency === "string"
? match.proficiency
: "";
}
if ("level" in next) {
next.level =
typeof newSkill.level === "number"
? newSkill.level
: typeof match.level === "number"
? match.level
: next.level;
}
if ("hidden" in next) {
next.hidden =
typeof newSkill.hidden === "boolean"
? newSkill.hidden
: typeof match.hidden === "boolean"
? match.hidden
: next.hidden;
}
if ("visible" in next) {
next.visible =
typeof newSkill.visible === "boolean"
? newSkill.visible
: typeof match.visible === "boolean"
? match.visible
: next.visible;
}
return next;
});
}
export function applyTailoredSkills(
mode: RxResumeMode,
resumeData: RecordLike,
tailoredSkills?: TailoredSkillsInput,
): void {
const parsed = parseTailoredSkills(tailoredSkills);
if (!parsed) {
if (mode === "v4") sanitizeV4SkillsSection(resumeData);
return;
}
if (mode === "v4") {
applyTailoredSkillsV4(resumeData, parsed);
return;
}
applyTailoredSkillsV5(resumeData, parsed);
}
export function extractProjectsFromResume(
mode: RxResumeMode,
resumeData: RecordLike,
): {
catalog: ResumeProjectCatalogItem[];
selectionItems: ResumeProjectSelectionItem[];
} {
const sections = asRecord(resumeData.sections);
const projectsSection = asRecord(sections?.projects);
const items = asArray(projectsSection?.items);
if (!items) return { catalog: [], selectionItems: [] };
const catalog: ResumeProjectCatalogItem[] = [];
const selectionItems: ResumeProjectSelectionItem[] = [];
for (const raw of items) {
const item = asRecord(raw);
if (!item) continue;
const id = typeof item.id === "string" ? item.id : "";
if (!id) continue;
const name = typeof item.name === "string" ? item.name : id;
const description =
typeof item.description === "string" ? item.description : "";
const date =
mode === "v5"
? typeof item.period === "string"
? item.period
: ""
: typeof item.date === "string"
? item.date
: "";
const isVisibleInBase =
mode === "v5"
? !(typeof item.hidden === "boolean" ? item.hidden : false)
: Boolean(item.visible);
const summaryRaw =
mode === "v5"
? description
: typeof item.summary === "string"
? item.summary
: "";
const base: ResumeProjectCatalogItem = {
id,
name,
description,
date,
isVisibleInBase,
};
catalog.push(base);
selectionItems.push({
...base,
summaryText: stripHtmlTags(summaryRaw),
});
}
return { catalog, selectionItems };
}
export function applyProjectVisibility(args: {
mode: RxResumeMode;
resumeData: RecordLike;
selectedProjectIds: ReadonlySet<string>;
forceVisibleProjectsSection?: boolean;
}): void {
const sections = asRecord(args.resumeData.sections);
const projectsSection = asRecord(sections?.projects);
const items = asArray(projectsSection?.items);
if (!projectsSection || !items) return;
for (const raw of items) {
const item = asRecord(raw);
if (!item) continue;
const id = typeof item.id === "string" ? item.id : "";
if (!id) continue;
if (args.mode === "v5") {
if ("hidden" in item) {
item.hidden = !args.selectedProjectIds.has(id);
} else if ("visible" in item) {
item.visible = args.selectedProjectIds.has(id);
}
} else {
item.visible = args.selectedProjectIds.has(id);
}
}
if (args.forceVisibleProjectsSection !== false) {
if (args.mode === "v5") {
if ("hidden" in projectsSection) {
projectsSection.hidden = false;
} else if ("visible" in projectsSection) {
projectsSection.visible = true;
}
} else {
projectsSection.visible = true;
}
}
}
export function applyTailoredChunks(args: {
mode: RxResumeMode;
resumeData: RecordLike;
tailoredContent: TailorChunkInput;
}): void {
applyTailoredSkills(args.mode, args.resumeData, args.tailoredContent.skills);
applyTailoredSummary(
args.mode,
args.resumeData,
args.tailoredContent.summary,
);
applyTailoredHeadline(
args.mode,
args.resumeData,
args.tailoredContent.headline,
);
}