API implementation for Reactive Resume

Merge pull request #10 from DaKheera47/reactive-resume-v5
This commit is contained in:
Shaheer Sarfaraz 2026-01-23 12:42:32 +00:00 committed by GitHub
commit 88db086d86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2167 additions and 1655 deletions

View File

@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@ -1550,6 +1551,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@ -2294,6 +2301,67 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",

View File

@ -30,6 +30,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",

View File

@ -168,14 +168,39 @@ export async function importManualJob(input: {
}
// Settings & Profile API
let settingsPromise: Promise<AppSettings> | null = null;
export async function getSettings(): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings');
if (settingsPromise) return settingsPromise;
settingsPromise = fetchApi<AppSettings>('/settings').finally(() => {
// Clear the promise after a short delay to allow subsequent fresh fetches
// but coalesce simultaneous requests.
setTimeout(() => {
settingsPromise = null;
}, 100);
});
return settingsPromise;
}
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> {
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
}
export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogItem[]> {
try {
const settings = await getSettings();
if (settings.rxresumeBaseResumeId) {
return await getRxResumeProjects(settings.rxresumeBaseResumeId);
}
} catch {
// fall through to profile-based projects
}
return getProfileProjects();
}
export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile');
}
@ -184,10 +209,9 @@ export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status');
}
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/upload', {
export async function refreshProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile/refresh', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
@ -205,7 +229,7 @@ export async function validateRxresume(email?: string, password?: string): Promi
});
}
export async function validateResumeJson(): Promise<ValidationResult> {
export async function validateResumeConfig(): Promise<ValidationResult> {
return fetchApi<ValidationResult>('/onboarding/validate/resume');
}
@ -235,6 +259,7 @@ export async function updateSettings(update: {
ukvisajobsEmail?: string | null
ukvisajobsPassword?: string | null
webhookSecret?: string | null
rxresumeBaseResumeId?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',
@ -242,6 +267,20 @@ export async function updateSettings(update: {
});
}
export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>('/settings/rx-resumes');
return data.resumes;
}
export async function getRxResumeProjects(resumeId: string, signal?: AbortSignal): Promise<ResumeProjectCatalogItem[]> {
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
{ signal }
);
return data.projects;
}
// Database API
export async function clearDatabase(): Promise<{
message: string;

View File

@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Check } from "lucide-react"
import { toast } from "sonner"
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils"
@ -13,17 +12,17 @@ import * as api from "@client/api"
import { useSettings } from "@client/hooks/useSettings"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
import type { ResumeProfile, ValidationResult } from "@shared/types"
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"
import type { ValidationResult } from "@shared/types"
type ValidationState = ValidationResult & { checked: boolean }
export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
const [isSavingEnv, setIsSavingEnv] = useState(false)
const [isUploadingResume, setIsUploadingResume] = useState(false)
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
const [isValidatingResume, setIsValidatingResume] = useState(false)
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false)
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
valid: false,
message: null,
@ -34,7 +33,7 @@ export const OnboardingGate: React.FC = () => {
message: null,
checked: false,
})
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
@ -44,24 +43,7 @@ export const OnboardingGate: React.FC = () => {
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [resumeFile, setResumeFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const validateResume = useCallback(async () => {
setIsValidatingResume(true)
try {
const result = await api.validateResumeJson()
setResumeValidation({ ...result, checked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : "Resume validation failed"
const result = { valid: false, message }
setResumeValidation({ ...result, checked: true })
return result
} finally {
setIsValidatingResume(false)
}
}, [])
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(null)
const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true)
@ -95,13 +77,27 @@ export const OnboardingGate: React.FC = () => {
}
}, [])
const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true)
try {
const result = await api.validateResumeConfig()
setBaseResumeValidation({ ...result, checked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : "Base resume validation failed"
const result = { valid: false, message }
setBaseResumeValidation({ ...result, checked: true })
return result
} finally {
setIsValidatingBaseResume(false)
}
}, [])
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
const hasBaseResume = resumeValidation.valid
const shouldOpen = Boolean(settings && !settingsLoading)
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
&& !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid)
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
@ -113,6 +109,12 @@ export const OnboardingGate: React.FC = () => {
? formatSecretHint(settings.rxresumePasswordHint)
: undefined
useEffect(() => {
if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null)
}
}, [settings])
const steps = useMemo(
() => [
{
@ -120,21 +122,24 @@ export const OnboardingGate: React.FC = () => {
label: "Connect AI",
subtitle: "OpenRouter key",
complete: openrouterValidation.valid,
disabled: false,
},
{
id: "rxresume",
label: "PDF Export",
subtitle: "RxResume login",
label: "Connect Reactive Resume",
subtitle: "Reactive Resume login",
complete: rxresumeValidation.valid,
disabled: false,
},
{
id: "resume",
label: "Resume JSON",
subtitle: "Upload your file",
complete: resumeValidation.valid,
id: "baseresume",
label: "Select Template Resume",
subtitle: "Template selection",
complete: baseResumeValidation.valid,
disabled: !rxresumeValidation.valid,
},
],
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid]
[openrouterValidation.valid, rxresumeValidation.valid, baseResumeValidation.valid]
)
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
@ -151,7 +156,7 @@ export const OnboardingGate: React.FC = () => {
const results = await Promise.allSettled([
validateOpenrouter(),
validateRxresume(),
validateResume(),
validateBaseResume(),
])
const failed = results.find((result) => result.status === "rejected")
@ -160,13 +165,13 @@ export const OnboardingGate: React.FC = () => {
const message = reason instanceof Error ? reason.message : "Validation checks failed"
toast.error(message)
}
}, [settings, validateOpenrouter, validateRxresume, validateResume])
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume])
useEffect(() => {
if (!settings || settingsLoading) return
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
if (openrouterValidation.checked || rxresumeValidation.checked || baseResumeValidation.checked) return
void runAllValidations()
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations])
const handleRefresh = async () => {
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
@ -254,57 +259,45 @@ export const OnboardingGate: React.FC = () => {
}
}
const handleUploadResume = async (): Promise<boolean> => {
if (!resumeFile) {
const validation = await validateResume()
if (!validation.valid) {
toast.info(validation.message || "Upload your resume JSON to continue")
return false
}
return true
const handleSaveBaseResume = async (): Promise<boolean> => {
if (!rxresumeBaseResumeId) {
toast.info("Select a base resume to continue")
return false
}
try {
setIsUploadingResume(true)
const text = await resumeFile.text()
let parsed: ResumeProfile
try {
parsed = JSON.parse(text) as ResumeProfile
} catch {
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
setIsSavingEnv(true)
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId })
const validation = await validateBaseResume()
if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed")
return false
}
await api.uploadProfile(parsed)
await validateResume()
setResumeFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
toast.success("Resume uploaded")
await refreshSettings()
toast.success("Base resume set")
return true
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to upload resume"
const message = error instanceof Error ? error.message : "Failed to save base resume"
toast.error(message)
return false
} finally {
setIsUploadingResume(false)
setIsSavingEnv(false)
}
}
const resumeFileName = resumeFile?.name || ""
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
const completedSteps = steps.filter((step) => step.complete).length
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume
const canGoBack = stepIndex > 0
const primaryLabel = currentStep === "resume"
? (resumeValidation.valid ? "Finish" : "Upload and validate")
: currentStep === "openrouter"
? (openrouterValidation.valid ? "Revalidate" : "Validate")
: currentStep === "rxresume"
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
const primaryLabel = currentStep === "openrouter"
? (openrouterValidation.valid ? "Revalidate" : "Validate")
: currentStep === "rxresume"
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
: currentStep === "baseresume"
? (baseResumeValidation.valid ? "Revalidate" : "Validate")
: "Validate"
const handlePrimaryAction = async () => {
@ -317,12 +310,9 @@ export const OnboardingGate: React.FC = () => {
await handleSaveRxresume()
return
}
if (currentStep === "resume") {
if (hasBaseResume) {
await handleRefresh()
return
}
await handleUploadResume()
if (currentStep === "baseresume") {
await handleSaveBaseResume()
return
}
}
@ -356,13 +346,17 @@ export const OnboardingGate: React.FC = () => {
return (
<FieldLabel
key={step.id}
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none"
className={cn(
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
step.disabled && "opacity-50 cursor-not-allowed"
)}
>
<TabsTrigger
value={step.id}
disabled={step.disabled}
className={cn(
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary !bg-muted/60 text-foreground" : "text-muted-foreground"
)}
>
<Field orientation="horizontal" className="items-start">
@ -439,30 +433,21 @@ export const OnboardingGate: React.FC = () => {
</div>
</TabsContent>
<TabsContent value="resume" className="space-y-4 pt-6">
<TabsContent value="baseresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Upload your resume JSON</p>
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label htmlFor="resumeFile" className="text-sm font-medium">
Resume JSON
</label>
<Input
id="resumeFile"
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
disabled={isUploadingResume}
/>
{resumeFileName && (
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
)}
</div>
<p className="text-sm font-semibold">Select your template resume</p>
<p className="text-xs text-muted-foreground">Choose the resume you want to use as a template.
The selected resume will be used as a template for tailoring.
</p>
</div>
<BaseResumeSelection
value={rxresumeBaseResumeId}
onValueChange={setRxresumeBaseResumeId}
hasRxResumeAccess={rxresumeValidation.valid}
disabled={isSavingEnv}
/>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">

View File

@ -79,7 +79,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Load project catalog once
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
}, []);
// Reset mode when job changes

View File

@ -55,7 +55,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
useEffect(() => {
// Load project catalog
api.getProfileProjects().then(setCatalog).catch(console.error);
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
// Set initial selection
if (job.selectedProjectIds) {

View File

@ -41,7 +41,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
const [showDescription, setShowDescription] = useState(false);
useEffect(() => {
api.getProfileProjects().then(setCatalog).catch(console.error);
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
}, []);
useEffect(() => {

View File

@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { PageHeader } from "@client/components/layout"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { AppSettings, JobStatus } from "@shared/types"
import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "@client/api"
import { arraysEqual } from "@/lib/utils"
@ -19,9 +19,9 @@ import { GradcrackerSection } from "@client/pages/settings/components/Gradcracke
import { JobspySection } from "@client/pages/settings/components/JobspySection"
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "",
@ -31,6 +31,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "",
resumeProjects: null,
rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
@ -60,6 +61,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
@ -89,6 +91,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
searchTerms: data.overrideSearchTerms,
@ -139,6 +142,35 @@ const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameStringList(value, defaultValue) ? null : value ?? null
const normalizeResumeProjectsForCatalog = (
catalog: ResumeProjectCatalogItem[],
current: ResumeProjectsSettings | null
): ResumeProjectsSettings | null => {
const allowed = new Set(catalog.map((project) => project.id))
const base = current ?? {
maxProjects: 0,
lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id),
aiSelectableProjectIds: [],
}
const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id))
const lockedSet = new Set(lockedProjectIds)
const aiSelectableProjectIds = (current
? base.aiSelectableProjectIds
: catalog.map((project) => project.id)
)
.filter((id) => allowed.has(id))
.filter((id) => !lockedSet.has(id))
const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw))
const maxProjects = Math.min(
catalog.length,
Math.max(lockedProjectIds.length, maxProjectsInt, 3)
)
return { maxProjects, lockedProjectIds, aiSelectableProjectIds }
}
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
isSameSortedStringList(value, defaultValue) ? null : value ?? null
@ -230,6 +262,9 @@ export const SettingsPage: React.FC = () => {
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<ResumeProjectCatalogItem[] | null>(null)
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false)
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
@ -237,7 +272,19 @@ export const SettingsPage: React.FC = () => {
defaultValues: DEFAULT_FORM_VALUES,
})
const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
const {
handleSubmit,
reset,
setError,
setValue,
getValues,
watch,
formState: { isDirty, errors, isValid, dirtyFields }
} = methods
const hasRxResumeAccess = Boolean(
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint
)
useEffect(() => {
let isMounted = true
@ -263,6 +310,62 @@ export const SettingsPage: React.FC = () => {
}
}, [reset])
useEffect(() => {
if (!settings) return
const storedId = settings.rxresumeBaseResumeId ?? null
setRxResumeBaseResumeIdDraft(storedId)
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false })
setRxResumeProjectsOverride(null)
}, [settings, setValue])
useEffect(() => {
let isMounted = true
const controller = new AbortController()
if (!rxResumeBaseResumeIdDraft) {
setRxResumeProjectsOverride(null)
return () => {
isMounted = false
controller.abort()
}
}
if (!hasRxResumeAccess) return () => {
isMounted = false
controller.abort()
}
setIsFetchingRxResumeProjects(true)
api
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
.then((projects) => {
if (!isMounted) return
setRxResumeProjectsOverride(projects)
const normalized = normalizeResumeProjectsForCatalog(
projects,
getValues("resumeProjects") ?? null
)
if (normalized) {
setValue("resumeProjects", normalized, { shouldDirty: true })
}
})
.catch((error) => {
if (!isMounted || error.name === 'AbortError') return
const message = error instanceof Error ? error.message : "Failed to load RxResume projects"
toast.error(message)
setRxResumeProjectsOverride(null)
})
.finally(() => {
if (!isMounted) return
setIsFetchingRxResumeProjects(false)
})
return () => {
isMounted = false
controller.abort()
}
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
const derived = getDerivedSettings(settings)
const {
model,
@ -279,6 +382,9 @@ export const SettingsPage: React.FC = () => {
maxProjectsTotal,
} = derived
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
const watchedValues = watch()
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
@ -357,6 +463,7 @@ export const SettingsPage: React.FC = () => {
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
@ -502,10 +609,17 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
profileProjects={profileProjects}
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={(value) => {
setRxResumeBaseResumeIdDraft(value)
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
}}
hasRxResumeAccess={hasRxResumeAccess}
profileProjects={effectiveProfileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
maxProjectsTotal={effectiveMaxProjectsTotal}
isProjectsLoading={isFetchingRxResumeProjects}
isLoading={isLoading}
isSaving={isSaving}
/>

View File

@ -0,0 +1,107 @@
import React, { useEffect, useState } from "react"
import { RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import * as api from "@client/api"
type BaseResumeSelectionProps = {
value: string | null
onValueChange: (value: string | null) => void
hasRxResumeAccess: boolean
disabled?: boolean
isLoading?: boolean
}
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
value,
onValueChange,
hasRxResumeAccess,
disabled = false,
isLoading = false,
}) => {
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const fetchResumes = async () => {
if (!hasRxResumeAccess) return
setIsFetchingResumes(true)
setFetchError(null)
try {
const data = await api.getRxResumes()
setResumes(data)
// Preselect if only one option is available and no value is currently set
if (data.length === 1 && !value) {
onValueChange(data[0].id)
}
} catch (error) {
setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes")
} finally {
setIsFetchingResumes(false)
}
}
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes()
}
}, [hasRxResumeAccess])
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Select
value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes}
>
<SelectTrigger>
<SelectValue placeholder={resumes.length > 0 ? "Select a template resume..." : "No resumes found"} />
</SelectTrigger>
<SelectContent>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
No resumes found in your account. Please create a resume on the{" "}
<a
href="https://rxresu.me"
target="_blank"
rel="noreferrer"
className="font-semibold underline underline-offset-2"
>
Reactive Resume website
</a>{" "}
first.
</div>
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,211 @@
import React from "react"
import { Controller, useFormContext } from "react-hook-form"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { clampInt } from "@/lib/utils"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
import { BaseResumeSelection } from "./BaseResumeSelection"
type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null
setRxResumeBaseResumeIdDraft: (value: string | null) => void
// True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
isProjectsLoading: boolean
isLoading: boolean
isSaving: boolean
}
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
profileProjects,
lockedCount,
maxProjectsTotal,
isProjectsLoading,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeAccess ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription>
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access.
</AlertDescription>
</Alert>
) : (
<>
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<Separator />
<div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Choose a PDF to configure resume projects.
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium">
Max projects to choose
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message}
</p>
)}
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Project</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Visible in template</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Must Include</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" - ")}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || isProjectsLoading || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -1,94 +0,0 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { ResumeProjectsSection } from "./ResumeProjectsSection"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
const profileProjects: ResumeProjectCatalogItem[] = [
{
id: "proj-1",
name: "Project One",
description: "Desc 1",
date: "2024",
isVisibleInBase: true,
},
{
id: "proj-2",
name: "Project Two",
description: "Desc 2",
date: "2023",
isVisibleInBase: false,
},
]
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
resumeProjects: initialDraft
}
})
const watched = methods.watch()
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("ResumeProjectsSection", () => {
it("clamps max projects to the locked count", async () => {
render(
<ResumeProjectsHarness
initialDraft={{
maxProjects: 2,
lockedProjectIds: ["proj-1"],
aiSelectableProjectIds: ["proj-2"],
}}
/>
)
const input = screen.getByRole("spinbutton")
fireEvent.change(input, { target: { value: "0" } })
await waitFor(() => expect(input).toHaveValue(1))
})
it("locks projects and enforces maxProjects >= locked count", () => {
render(
<ResumeProjectsHarness
initialDraft={{
maxProjects: 0,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1"],
}}
/>
)
const checkboxes = screen.getAllByRole("checkbox")
const lockedCheckbox = checkboxes[0]
const aiSelectableCheckbox = checkboxes[1]
fireEvent.click(lockedCheckbox)
expect(lockedCheckbox).toBeChecked()
expect(aiSelectableCheckbox).toBeChecked()
expect(aiSelectableCheckbox).toBeDisabled()
const input = screen.getByRole("spinbutton")
expect(input).toHaveValue(1)
})
})

View File

@ -1,161 +0,0 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { clampInt } from "@/lib/utils"
import { UpdateSettingsInput } from "@shared/settings-schema"
type ResumeProjectsSectionProps = {
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
isLoading: boolean
isSaving: boolean
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume Projects</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
<div className="text-xs text-muted-foreground">
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
</div>
</div>
<Separator />
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton >
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js';
import { RxResumeClient } from '@server/services/rxresume-client.js';
@ -154,67 +152,19 @@ describe.sequential('Onboarding API routes', () => {
});
describe('GET /api/onboarding/validate/resume', () => {
it('returns invalid when no resume file exists', async () => {
it('returns invalid when rxresumeBaseResumeId is not configured', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
expect(body.data.message).toContain('No base resume selected');
});
it('returns invalid when resume file is empty', async () => {
// Create an empty resume file
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
});
it('returns invalid when resume file is invalid JSON', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, 'not valid json {{{');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
});
it('returns invalid with field path when resume does not match schema', async () => {
const resumePath = join(tempDir, 'resume.json');
// Valid JSON but missing required fields
await writeFile(resumePath, JSON.stringify({ foo: 'bar' }));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
// Should include field path in error message
expect(body.data.message).toBeTruthy();
});
it('returns valid when resume file is valid and matches schema', async () => {
const resumePath = join(tempDir, 'resume.json');
const validResume = createMinimalValidResume();
await writeFile(resumePath, JSON.stringify(validResume));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
});
// Note: Further validation tests require mocking getSetting and getResume
// which is complex in integration tests. The validation logic is covered
// by unit tests in profile.test.ts and the service tests.
});
});

View File

@ -1,9 +1,9 @@
import { Router, Request, Response } from 'express';
import { readFile, stat } from 'fs/promises';
import { resumeDataSchema } from '@shared/rxresume-schema.js';
import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js';
import { RxResumeClient } from '@server/services/rxresume-client.js';
import { getSetting } from '@server/repositories/settings.js';
import { getResume, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
export const onboardingRouter = Router();
@ -55,29 +55,51 @@ async function validateOpenrouter(apiKey?: string | null): Promise<ValidationRes
}
}
async function validateResumeJson(): Promise<ValidationResponse> {
/**
* Validate that a base resume is configured and accessible via RxResume v4 API.
*/
async function validateResumeConfig(): Promise<ValidationResponse> {
try {
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
if (!fileInfo.isFile() || fileInfo.size === 0) {
return { valid: false, message: 'Resume JSON is missing.' };
// Check if rxresumeBaseResumeId is configured
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
if (!rxresumeBaseResumeId) {
return {
valid: false,
message: 'No base resume selected. Please select a resume from your RxResume account in Settings.'
};
}
const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8');
const parsed = JSON.parse(raw);
const result = resumeDataSchema.safeParse(parsed);
if (!result.success) {
const issue = result.error.issues[0];
const path = issue?.path?.join('.') || '';
const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.';
const details = path
? `Field "${path}": ${baseMessage}`
: baseMessage;
return { valid: false, message: details };
}
// Verify the resume is accessible and valid
try {
const resume = await getResume(rxresumeBaseResumeId);
return { valid: true, message: null };
if (!resume.data || typeof resume.data !== 'object') {
return { valid: false, message: 'Selected resume is empty or invalid.' };
}
// Validate against schema
const result = resumeDataSchema.safeParse(resume.data);
if (!result.success) {
const issue = result.error.issues[0];
const path = issue?.path?.join('.') || '';
const baseMessage = issue?.message ?? 'Resume does not match the expected schema.';
const details = path
? `Field "${path}": ${baseMessage}`
: baseMessage;
return { valid: false, message: details };
}
return { valid: true, message: null };
} catch (error) {
if (error instanceof RxResumeCredentialsError) {
return { valid: false, message: 'RxResume credentials not configured.' };
}
const message = error instanceof Error ? error.message : 'Failed to fetch resume from RxResume.';
return { valid: false, message };
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
const message = error instanceof Error ? error.message : 'Resume validation failed.';
return { valid: false, message };
}
}
@ -119,6 +141,6 @@ onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response)
});
onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
const result = await validateResumeJson();
const result = await validateResumeConfig();
res.json({ success: true, data: result });
});

View File

@ -1,9 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { writeFile, stat } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js';
// Mock the rxresume-v4 service
vi.mock('../../services/rxresume-v4.js', () => ({
getResume: vi.fn(),
listResumes: vi.fn(),
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
constructor() {
super('RxResume credentials not configured.');
this.name = 'RxResumeCredentialsError';
}
},
}));
// Mock the profile service
vi.mock('../../services/profile.js', () => ({
getProfile: vi.fn(),
clearProfileCache: vi.fn(),
}));
// Mock the settings repository
vi.mock('../../repositories/settings.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
getSetting: vi.fn(),
};
});
import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js';
import { getProfile } from '../../services/profile.js';
import { getSetting } from '../../repositories/settings.js';
describe.sequential('Profile API routes', () => {
let server: Server;
let baseUrl: string;
@ -11,6 +40,7 @@ describe.sequential('Profile API routes', () => {
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
@ -18,73 +48,88 @@ describe.sequential('Profile API routes', () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns empty projects when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
describe('GET /api/profile/projects', () => {
it('returns projects when profile is configured', async () => {
const mockProfile = {
sections: {
projects: {
items: [
{ id: 'proj1', name: 'Project 1', description: 'Desc 1', date: '2024', visible: true },
{ id: 'proj2', name: 'Project 2', description: 'Desc 2', date: '2023', visible: false },
],
},
},
};
vi.mocked(getProfile).mockResolvedValue(mockProfile);
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual([]);
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBe(2);
});
it('returns error when profile is not configured', async () => {
vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.'));
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain('Base resume not configured');
});
});
it('returns null profile when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
describe('GET /api/profile', () => {
it('returns full profile when configured', async () => {
const mockProfile = {
basics: { name: 'Test User', headline: 'Developer' },
sections: { summary: { content: 'A summary' } },
};
vi.mocked(getProfile).mockResolvedValue(mockProfile);
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toBeNull();
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual(mockProfile);
});
it('returns error when profile is not configured', async () => {
vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.'));
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain('Base resume not configured');
});
});
it('returns base resume projects', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
it('returns full base resume profile', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data).toBeDefined();
expect(typeof body.data).toBe('object');
});
describe('GET /api/profile/status', () => {
it('returns exists: false when resume file does not exist', async () => {
it('returns exists: false when rxresumeBaseResumeId is not configured', async () => {
vi.mocked(getSetting).mockResolvedValue(null);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toBeTruthy();
expect(body.data.error).toContain('No base resume selected');
});
it('returns exists: false when resume file is empty', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.exists).toBe(false);
});
it('returns exists: true when valid resume file exists', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
it('returns exists: true when resume is accessible', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: { basics: { name: 'Test' } },
} as any);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
@ -94,160 +139,38 @@ describe.sequential('Profile API routes', () => {
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
});
describe('POST /api/profile/upload', () => {
it('rejects request without profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const body = await res.json();
it('returns exists: false when RxResume credentials are missing', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects array as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: [] }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects primitive as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: 'not an object' }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects invalid resume with detailed field path in error', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: { foo: 'bar' } }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid resume JSON');
// Should include field path in error message
expect(body.error).toMatch(/Field "[^"]+"/);
});
it('accepts valid resume and creates file', async () => {
const validResume = createMinimalValidResume();
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: validResume }),
});
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
// Verify file was created
const resumePath = join(tempDir, 'resume.json');
const fileInfo = await stat(resumePath);
expect(fileInfo.isFile()).toBe(true);
expect(fileInfo.size).toBeGreaterThan(0);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain('credentials not configured');
});
it('overwrites existing resume file', async () => {
const resumePath = join(tempDir, 'resume.json');
const oldResume = createMinimalValidResume();
oldResume.basics.name = 'Old Name';
await writeFile(resumePath, JSON.stringify(oldResume));
it('returns exists: false when resume data is empty', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: null,
} as any);
const newResume = createMinimalValidResume();
newResume.basics.name = 'New Name';
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: newResume }),
});
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
// Verify profile was updated
const profileRes = await fetch(`${baseUrl}/api/profile`);
const profileBody = await profileRes.json();
expect(profileBody.data.basics.name).toBe('New Name');
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain('empty or invalid');
});
});
// Note: POST /api/profile/refresh tests skipped because basic auth blocks POST in test environment
// The endpoint is tested indirectly through the profile service tests
});
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function createMinimalValidResume() {
return {
basics: {
name: 'Test User',
headline: 'Software Developer',
email: 'test@example.com',
phone: '',
location: '',
url: { label: '', href: '' },
customFields: [],
picture: {
url: '',
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
custom: {},
},
metadata: {
template: 'rhyhorn',
layout: [[['summary'], ['skills']]],
css: { value: '', visible: false },
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
typography: {
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: '',
},
};
}

View File

@ -1,30 +1,16 @@
import { Router, Request, Response } from 'express';
import { mkdir, stat, writeFile } from 'fs/promises';
import { dirname } from 'path';
import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js';
import { resumeDataSchema } from '@shared/rxresume-schema.js';
import { getProfile, clearProfileCache } from '../../services/profile.js';
import { getSetting } from '../../repositories/settings.js';
import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js';
export const profileRouter = Router();
async function profileExists(): Promise<boolean> {
try {
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
return fileInfo.isFile() && fileInfo.size > 0;
} catch {
return false;
}
}
/**
* GET /api/profile/projects - Get all projects available in the base resume
*/
profileRouter.get('/projects', async (req: Request, res: Response) => {
try {
if (!(await profileExists())) {
res.json({ success: true, data: [] });
return;
}
const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog });
@ -39,10 +25,6 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
*/
profileRouter.get('/', async (req: Request, res: Response) => {
try {
if (!(await profileExists())) {
res.json({ success: true, data: null });
return;
}
const profile = await getProfile();
res.json({ success: true, data: profile });
} catch (error) {
@ -52,13 +34,51 @@ profileRouter.get('/', async (req: Request, res: Response) => {
});
/**
* GET /api/profile/status - Check if base resume exists
* GET /api/profile/status - Check if base resume is configured and accessible
*/
profileRouter.get('/status', async (_req: Request, res: Response) => {
try {
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
const exists = fileInfo.isFile() && fileInfo.size > 0;
res.json({ success: true, data: { exists, error: exists ? null : 'Resume file is empty' } });
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
if (!rxresumeBaseResumeId) {
res.json({
success: true,
data: {
exists: false,
error: 'No base resume selected. Please select a resume from your RxResume account in Settings.'
}
});
return;
}
// Verify the resume is accessible
try {
const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== 'object') {
res.json({
success: true,
data: {
exists: false,
error: 'Selected resume is empty or invalid.'
}
});
return;
}
res.json({ success: true, data: { exists: true, error: null } });
} catch (error) {
if (error instanceof RxResumeCredentialsError) {
res.json({
success: true,
data: {
exists: false,
error: 'RxResume credentials not configured.'
}
});
return;
}
throw error;
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.json({ success: true, data: { exists: false, error: message } });
@ -66,43 +86,15 @@ profileRouter.get('/status', async (_req: Request, res: Response) => {
});
/**
* POST /api/profile/upload - Upload base resume JSON
* POST /api/profile/refresh - Clear profile cache and refetch from RxResume v4 API
*/
profileRouter.post('/upload', async (req: Request, res: Response) => {
profileRouter.post('/refresh', async (_req: Request, res: Response) => {
try {
const profile = (req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>).profile : null) as unknown;
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
throw new Error('Invalid profile payload. Expected a JSON object.');
}
const parsed = resumeDataSchema.safeParse(profile);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const path = issue?.path?.join('.') || '';
const baseMessage = issue?.message ?? 'Resume JSON does not match the RxResume schema.';
const details = path ? `Field "${path}": ${baseMessage}` : baseMessage;
throw new Error(`Invalid resume JSON: ${details}`);
}
const existing = await stat(DEFAULT_PROFILE_PATH).catch(() => null);
if (existing && existing.isDirectory()) {
throw new Error('Resume path is a directory. Remove it and upload again.');
}
await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true });
await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(parsed.data, null, 2), 'utf-8');
clearProfileCache();
res.json({ success: true, data: { exists: true, error: null } });
const profile = await getProfile(true);
res.json({ success: true, data: profile });
} catch (error) {
let message = error instanceof Error ? error.message : 'Unknown error';
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
if (code === 'EROFS') {
message = 'Resume path is read-only. Remove the bind mount and restart the container.';
}
}
res.status(400).json({ success: false, error: message });
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -11,6 +11,7 @@ import {
} from '@server/services/resumeProjects.js';
import { getProfile } from '@server/services/profile.js';
import { getEffectiveSettings } from '@server/services/settings.js';
import { getResume, listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
export const settingsRouter = Router();
@ -57,6 +58,10 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
}
if ('rxresumeBaseResumeId' in input) {
promises.push(settingsRepo.setSetting('rxresumeBaseResumeId', normalizeEnvInput(input.rxresumeBaseResumeId)));
}
if ('resumeProjects' in input) {
const resumeProjects = input.resumeProjects ?? null;
@ -64,13 +69,8 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
promises.push(settingsRepo.setSetting('resumeProjects', null));
} else {
promises.push((async () => {
const rawProfile = await getProfile();
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
throw new Error('Invalid resume profile format: expected a non-null object');
}
const profile = rawProfile as Record<string, unknown>;
// getProfile() will fetch from RxResume v4 API using rxresumeBaseResumeId
const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile);
const allowed = new Set(catalog.map((p) => p.id));
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
@ -192,3 +192,55 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
res.status(400).json({ success: false, error: message });
}
});
/**
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume v4 API
*/
settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => {
try {
const resumes = await listResumes();
// Map to expected format (id, name)
res.json({
success: true,
data: {
resumes: resumes.map((resume) => ({ id: resume.id, name: resume.name })),
},
});
} catch (error) {
if (error instanceof RxResumeCredentialsError) {
res.status(400).json({ success: false, error: error.message });
return;
}
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ Failed to fetch Reactive Resumes: ${message}`);
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from RxResume v4
*/
settingsRouter.get('/rx-resumes/:id/projects', async (req: Request, res: Response) => {
try {
const resumeId = req.params.id;
if (!resumeId) {
res.status(400).json({ success: false, error: 'Resume id is required.' });
return;
}
const resume = await getResume(resumeId);
const profile = resume.data ?? {};
const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: { projects: catalog } });
} catch (error) {
if (error instanceof RxResumeCredentialsError) {
res.status(400).json({ success: false, error: error.message });
return;
}
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ Failed to fetch RxResume projects: ${message}`);
res.status(500).json({ success: false, error: message });
}
});

View File

@ -7,9 +7,7 @@
* 3. Leave all jobs in "discovered" for manual processing
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { runCrawler } from '../services/crawler.js';
import { runJobSpy } from '../services/jobspy.js';
import { runUkVisaJobs } from '../services/ukvisajobs.js';
@ -28,14 +26,10 @@ import { progressHelpers, resetProgress, updateProgress } from './progress.js';
import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js';
import { getDataDir } from '../config/dataDir.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_PROFILE_PATH = join(__dirname, '../../../../resume-generator/base.json');
const DEFAULT_CONFIG: PipelineConfig = {
topN: 10,
minSuitabilityScore: 50,
sources: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'],
profilePath: DEFAULT_PROFILE_PATH,
outputDir: join(getDataDir(), 'pdfs'),
enableCrawling: true,
enableScoring: true,
@ -113,7 +107,10 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
try {
// Step 1: Load profile
console.log('\n📋 Loading profile...');
const profile = await getProfile(mergedConfig.profilePath);
const profile = await getProfile().catch((error) => {
console.warn('⚠️ Failed to load profile for scoring, using empty profile:', error);
return {} as Record<string, unknown>;
});
// Step 2: Run crawler
console.log('\n🕷 Running crawler...');
@ -350,7 +347,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
// Process job (Generate Summary + PDF)
// We catch errors here to ensure one failure doesn't stop the whole batch
const result = await processJob(job.id, { profilePath: mergedConfig.profilePath });
const result = await processJob(job.id, { force: false });
if (result.success) {
processedCount++;
@ -419,7 +416,6 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
export type ProcessJobOptions = {
force?: boolean;
profilePath?: string;
};
/**
@ -438,7 +434,7 @@ export async function summarizeJob(
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: 'Job not found' };
const profile = await getProfile(options?.profilePath);
const profile = await getProfile();
// 1. Generate Summary & Tailoring
let tailoredSummary = job.tailoredSummary;
@ -522,7 +518,7 @@ export async function generateFinalPdf(
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
},
job.jobDescription || '',
options?.profilePath || DEFAULT_PROFILE_PATH,
undefined, // deprecated baseResumePath parameter
job.selectedProjectIds
);
@ -575,4 +571,3 @@ export async function processJob(
export function getPipelineStatus(): { isRunning: boolean } {
return { isRunning: isPipelineRunning };
}

View File

@ -14,6 +14,7 @@ export type SettingKey = 'model'
| 'pipelineWebhookUrl'
| 'jobCompleteWebhookUrl'
| 'resumeProjects'
| 'rxresumeBaseResumeId'
| 'ukvisajobsMaxJobs'
| 'gradcrackerMaxJobsPerTerm'
| 'searchTerms'

View File

@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generatePdf } from './pdf.js';
import { getProfile } from './profile.js';
// Define mock data in hoisted block
const { mocks, mockProfile } = vi.hoisted(() => {
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
const profile = {
sections: {
summary: { content: 'Original Summary' },
@ -17,6 +17,24 @@ const { mocks, mockProfile } = vi.hoisted(() => {
basics: { headline: 'Original Headline' }
};
// Capture what's passed to create()
let lastCreateData: any = null;
const mockClient = {
create: vi.fn().mockImplementation((data: any) => {
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
return Promise.resolve('mock-resume-id');
}),
print: vi.fn().mockResolvedValue('https://example.com/pdf/mock.pdf'),
delete: vi.fn().mockResolvedValue(undefined),
withAutoRefresh: vi.fn().mockImplementation(async (_email: string, _password: string, operation: (token: string) => Promise<any>) => {
return operation('mock-token');
}),
getToken: vi.fn().mockResolvedValue('mock-token'),
getLastCreateData: () => lastCreateData,
clearLastCreateData: () => { lastCreateData = null; },
};
return {
mockProfile: profile,
mocks: {
@ -25,7 +43,8 @@ const { mocks, mockProfile } = vi.hoisted(() => {
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
}
},
mockRxResumeClient: mockClient,
};
});
@ -42,14 +61,35 @@ vi.mock('fs/promises', async () => {
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: vi.fn().mockReturnValue(true) }
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
default: {
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
}
}));
vi.mock('../repositories/settings.js', () => ({
getSetting: vi.fn().mockResolvedValue(null),
getSetting: vi.fn().mockImplementation((key: string) => {
if (key === 'rxresumeEmail') return Promise.resolve('test@example.com');
if (key === 'rxresumePassword') return Promise.resolve('testpassword');
return Promise.resolve(null);
}),
getAllSettings: vi.fn().mockResolvedValue({}),
}));
// Mock the profile service - getProfile now fetches from v4 API
vi.mock('./profile.js', () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile),
}));
vi.mock('./projectSelection.js', () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
}));
@ -61,31 +101,50 @@ vi.mock('./resumeProjects.js', () => ({
})
}));
vi.mock('child_process', () => ({
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
})),
default: {
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
}))
// Mock the RxResumeClient
vi.mock('./rxresume-client.js', () => ({
RxResumeClient: class {
constructor() {
return mockRxResumeClient;
}
}
}));
// Mock stream pipeline for downloading PDF
vi.mock('stream/promises', () => ({
pipeline: vi.fn().mockResolvedValue(undefined),
default: {
pipeline: vi.fn().mockResolvedValue(undefined),
}
}));
// Mock stream Readable
vi.mock('stream', () => ({
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
default: {
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
}
}));
// Mock global fetch for PDF download
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
body: {},
}));
describe('PDF Service Skills Validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
vi.mocked(getProfile).mockResolvedValue(mockProfile);
mockRxResumeClient.clearLastCreateData();
});
it('should add required schema fields (visible, description) to new skills', async () => {
@ -99,9 +158,8 @@ describe('PDF Service Skills Validation', () => {
await generatePdf('job-skills-1', tailoredContent, 'Job Desc');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const skillItems = savedResumeJson.sections.skills.items;
@ -141,14 +199,13 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile));
vi.mocked(getProfile).mockResolvedValueOnce(invalidProfile);
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const item = savedResumeJson.sections.skills.items[0];
@ -173,13 +230,12 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const skillItems = savedResumeJson.sections.skills.items;
@ -211,13 +267,12 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const skill = savedResumeJson.sections.skills.items[0];
@ -241,13 +296,12 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithValidId);
await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const skill = savedResumeJson.sections.skills.items[0];

View File

@ -1,33 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as projectSelection from './projectSelection.js';
import { generatePdf } from './pdf.js';
// Define mock data in hoisted block
const { mocks, mockProfile } = vi.hoisted(() => {
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
const profile = {
sections: {
summary: { content: 'Original Summary' },
skills: { items: ['Original Skill'] },
projects: {
projects: {
items: [
// Start with visible=true to test if they get hidden
{ id: 'p1', name: 'Project 1', visible: true },
{ id: 'p2', name: 'Project 2', visible: true }
]
]
}
},
basics: { headline: 'Original Headline' }
};
// Capture what's passed to create()
let lastCreateData: any = null;
const mockClient = {
create: vi.fn().mockImplementation((data: any) => {
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
return Promise.resolve('mock-resume-id');
}),
print: vi.fn().mockResolvedValue('https://example.com/pdf/mock.pdf'),
delete: vi.fn().mockResolvedValue(undefined),
withAutoRefresh: vi.fn().mockImplementation(async (_email: string, _password: string, operation: (token: string) => Promise<any>) => {
return operation('mock-token');
}),
getToken: vi.fn().mockResolvedValue('mock-token'),
getLastCreateData: () => lastCreateData,
clearLastCreateData: () => { lastCreateData = null; },
};
return {
mockProfile: profile,
mocks: {
readFile: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
}
},
mockRxResumeClient: mockClient,
};
});
@ -44,12 +64,33 @@ vi.mock('fs/promises', async () => {
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: vi.fn().mockReturnValue(true) }
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
default: {
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
}
}));
vi.mock('../repositories/settings.js', () => ({
getSetting: vi.fn().mockResolvedValue(null),
getAllSettings: vi.fn().mockResolvedValue({}),
getSetting: vi.fn().mockImplementation((key: string) => {
if (key === 'rxresumeEmail') return Promise.resolve('test@example.com');
if (key === 'rxresumePassword') return Promise.resolve('testpassword');
return Promise.resolve(null);
}),
getAllSettings: vi.fn().mockResolvedValue({}),
}));
// Mock the profile service - getProfile now fetches from v4 API
vi.mock('./profile.js', () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile),
}));
vi.mock('./projectSelection.js', () => ({
@ -73,75 +114,88 @@ vi.mock('./resumeProjects.js', () => ({
})
}));
vi.mock('child_process', () => ({
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
})),
default: {
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn().mockImplementation((event, cb) => {
if (event === 'close') cb(0);
return {};
}),
}))
// Mock the RxResumeClient
vi.mock('./rxresume-client.js', () => ({
RxResumeClient: class {
constructor() {
return mockRxResumeClient;
}
}
}));
import { generatePdf } from './pdf.js';
// Mock stream pipeline for downloading PDF
vi.mock('stream/promises', () => ({
pipeline: vi.fn().mockResolvedValue(undefined),
default: {
pipeline: vi.fn().mockResolvedValue(undefined),
}
}));
// Mock stream Readable
vi.mock('stream', () => ({
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
default: {
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
}
}));
// Mock global fetch
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
body: {},
}));
describe('PDF Service Tailoring Logic', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset default behaviors
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mocks.writeFile.mockResolvedValue(undefined);
mockRxResumeClient.clearLastCreateData();
});
it('should use provided selectedProjectIds and BYPASS AI selection', async () => {
const tailoredContent = { summary: 'New Sum', headline: 'New Head', skills: [] };
await generatePdf('job-1', tailoredContent, 'Job Desc', 'base.json', 'p2');
// 1. pickProjectIdsForJob should NOT be called
expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled();
// 2. Verify writeFile content
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
// 2. Verify create data content
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const projects = savedResumeJson.sections.projects.items;
const p1 = projects.find((p: any) => p.id === 'p1');
const p2 = projects.find((p: any) => p.id === 'p2');
expect(p2.visible).toBe(true);
expect(p1.visible).toBe(false);
expect(p1.visible).toBe(false);
// 3. Verify Summary Update
const summary = savedResumeJson.sections.summary.content;
expect(summary).toBe('New Sum');
expect(summary).toBe('New Sum');
});
it('should handle comma-separated project IDs correctly', async () => {
await generatePdf('job-2', {}, 'desc', 'base.json', 'p1, p2 ');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const projects = savedResumeJson.sections.projects.items;
expect(projects.find((p: any) => p.id === 'p1').visible).toBe(true);
expect(projects.find((p: any) => p.id === 'p2').visible).toBe(true);
});
it('should fall back to AI selection if selectedProjectIds is null/undefined', async () => {
// Setup AI selection mock for this test
vi.mocked(projectSelection.pickProjectIdsForJob).mockResolvedValue(['p1']);
@ -149,18 +203,17 @@ describe('PDF Service Tailoring Logic', () => {
await generatePdf('job-3', {}, 'desc', 'base.json', undefined);
expect(projectSelection.pickProjectIdsForJob).toHaveBeenCalled();
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const p1 = savedResumeJson.sections.projects.items.find((p: any) => p.id === 'p1');
const p2 = savedResumeJson.sections.projects.items.find((p: any) => p.id === 'p2');
expect(p1.visible).toBe(true);
expect(p2.visible).toBe(false);
const visibleCount = savedResumeJson.sections.projects.items.filter((p:any) => p.visible).length;
const visibleCount = savedResumeJson.sections.projects.items.filter((p: any) => p.visible).length;
expect(visibleCount).toBe(1);
});
});

View File

@ -1,25 +1,21 @@
/**
* Service for generating PDF resumes using RXResume.
* Wraps the existing Python rxresume_automation.py script.
* Service for generating PDF resumes using RxResume v4 API.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readFile, writeFile, mkdir, access, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { mkdir, access } from 'fs/promises';
import { existsSync, createWriteStream } from 'fs';
import { createId } from '@paralleldrive/cuid2';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
import { getSetting } from '../repositories/settings.js';
import { pickProjectIdsForJob } from './projectSelection.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
import { getDataDir } from '../config/dataDir.js';
import { getProfile } from './profile.js';
import { RxResumeClient } from './rxresume-client.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Paths - can be overridden via env for Docker
const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(__dirname, '../../../../resume-generator');
const OUTPUT_DIR = join(getDataDir(), 'pdfs');
export interface PdfResult {
@ -31,26 +27,76 @@ export interface PdfResult {
export interface TailoredPdfContent {
summary?: string | null;
headline?: string | null;
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
}
/**
* Generate a tailored PDF resume for a job.
* Get RxResume credentials from environment variables or database settings.
*/
async function getCredentials(): Promise<{ email: string; password: string; baseUrl: string }> {
// First check environment variables
let email = process.env.RXRESUME_EMAIL || '';
let password = process.env.RXRESUME_PASSWORD || '';
const baseUrl = process.env.RXRESUME_URL || 'https://v4.rxresu.me';
// Fall back to database settings if env vars are not set
if (!email) {
email = (await getSetting('rxresumeEmail')) || '';
}
if (!password) {
password = (await getSetting('rxresumePassword')) || '';
}
if (!email || !password) {
throw new Error(
'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD environment variables or configure them in settings.'
);
}
return { email, password, baseUrl };
}
/**
* Download a file from a URL and save it to a local path.
*/
async function downloadFile(url: string, outputPath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download PDF: HTTP ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('No response body from PDF download');
}
// Convert Web ReadableStream to Node readable
const nodeReadable = Readable.fromWeb(response.body as any);
const fileStream = createWriteStream(outputPath);
await pipeline(nodeReadable, fileStream);
}
/**
* Generate a tailored PDF resume for a job using the RxResume v4 API.
*
* @param jobId - Unique job identifier
* @param tailoredContent - Content to inject (summary, headline, skills)
* @param jobDescription - Job description (for project selection)
* @param baseResumePath - Optional path to base JSON
* @param selectedProjectIds - Optional overrides
* Flow:
* 1. Prepare resume data with tailored content and project selection
* 2. Get auth token (uses cached token or logs in)
* 3. Import/create resume on RxResume
* 4. Request print to get PDF URL
* 5. Download PDF locally
* 6. Delete temporary resume from RxResume
*
* Token refresh is handled automatically on 401 errors.
*/
export async function generatePdf(
jobId: string,
tailoredContent: TailoredPdfContent,
jobDescription: string,
baseResumePath?: string,
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
selectedProjectIds?: string | null
): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId}...`);
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
try {
// Ensure output directory exists
@ -58,10 +104,12 @@ export async function generatePdf(
await mkdir(OUTPUT_DIR, { recursive: true });
}
// Read base resume
const baseResume = baseResumePath
? JSON.parse(await readFile(baseResumePath, 'utf-8'))
: JSON.parse(JSON.stringify(await getProfile())); // Deep copy from cache
// Get credentials and initialize client
const { email, password, baseUrl } = await getCredentials();
const client = new RxResumeClient(baseUrl);
// Read base resume from profile (fetches from v4 API if configured)
const baseResume = JSON.parse(JSON.stringify(await getProfile()));
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
@ -89,7 +137,6 @@ export async function generatePdf(
// Inject tailored headline
if (tailoredContent.headline) {
if (baseResume.basics) {
// Support both standard JSON Resume 'label' and RxResume 'headline'
baseResume.basics.headline = tailoredContent.headline;
baseResume.basics.label = tailoredContent.headline;
}
@ -124,7 +171,7 @@ export async function generatePdf(
}
}
// Select projects (manual override OR locked + AI-picked) and set visibility for RXResume
// Select projects and set visibility
try {
let selectedSet: Set<string>;
@ -149,7 +196,7 @@ export async function generatePdf(
selectedSet = new Set([...locked, ...picked]);
}
const projectsSection = (baseResume as any)?.sections?.projects;
const projectsSection = baseResume.sections?.projects;
const projectItems = projectsSection?.items;
if (Array.isArray(projectItems)) {
for (const item of projectItems) {
@ -164,32 +211,47 @@ export async function generatePdf(
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
}
// Write modified resume to temp file
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
// Use withAutoRefresh to handle token caching and 401 retry automatically
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
// Generate PDF using Python script - output directly to our data folder
const outputFilename = `resume_${jobId}.pdf`;
const outputPath = join(OUTPUT_DIR, outputFilename);
await client.withAutoRefresh(email, password, async (token) => {
let resumeId: string | null = null;
// Ensure regeneration overwrites the old file if it exists.
try {
await unlink(outputPath);
} catch {
// Ignore if it doesn't exist or cannot be removed.
}
try {
// Create resume on RxResume
console.log(` 📤 Uploading resume to RxResume...`);
resumeId = await client.create(baseResume, token);
console.log(` ✅ Resume created with ID: ${resumeId}`);
await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR);
// Get PDF URL
console.log(` 🖨️ Requesting PDF generation...`);
const pdfUrl = await client.print(resumeId, token);
console.log(` ✅ PDF URL received: ${pdfUrl}`);
// Cleanup temp file
try {
const { unlink } = await import('fs/promises');
await unlink(tempResumePath);
} catch {
// Ignore cleanup errors
}
// Download PDF
console.log(` 📥 Downloading PDF...`);
await downloadFile(pdfUrl, outputPath);
console.log(` ✅ PDF saved to: ${outputPath}`);
console.log(`✅ PDF generated: ${outputPath}`);
// Cleanup: delete temporary resume from RxResume
console.log(` 🧹 Cleaning up temporary resume...`);
await client.delete(resumeId, token);
console.log(` ✅ Temporary resume deleted from RxResume`);
resumeId = null;
} finally {
// Attempt cleanup if resume was created but not deleted
if (resumeId) {
try {
console.log(` 🧹 Attempting cleanup of orphaned resume...`);
await client.delete(resumeId, token);
} catch {
console.warn(` ⚠️ Failed to cleanup orphaned resume ${resumeId}`);
}
}
}
});
console.log(`✅ PDF generated successfully: ${outputPath}`);
return { success: true, pdfPath: outputPath };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@ -198,41 +260,6 @@ export async function generatePdf(
}
}
/**
* Run the Python RXResume automation script.
*/
async function runPythonPdfGenerator(
jsonPath: string,
outputFilename: string,
outputDir: string
): Promise<void> {
return new Promise((resolve, reject) => {
// Use the virtual environment's Python (or system python in Docker)
const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python');
const child = spawn(pythonPath, ['rxresume_automation.py'], {
cwd: RESUME_GEN_DIR,
env: {
...process.env,
RESUME_JSON_PATH: jsonPath,
OUTPUT_FILENAME: outputFilename,
OUTPUT_DIR: outputDir,
},
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python script exited with code ${code}`));
}
});
child.on('error', reject);
});
}
/**
* Check if a PDF exists for a job.
*/

View File

@ -1,32 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFile } from 'fs/promises';
import { getProfile } from './profile.js';
import { getProfile, clearProfileCache } from './profile.js';
vi.mock('fs/promises', async () => {
const fn = vi.fn();
return {
readFile: fn,
default: {
readFile: fn
// Mock the dependencies
vi.mock('../repositories/settings.js', () => ({
getSetting: vi.fn(),
}));
vi.mock('./rxresume-v4.js', () => ({
getResume: vi.fn(),
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
constructor() {
super('RxResume credentials not configured.');
this.name = 'RxResumeCredentialsError';
}
};
});
},
}));
describe('getProfile failure', () => {
import { getSetting } from '../repositories/settings.js';
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
describe('getProfile', () => {
beforeEach(() => {
vi.resetAllMocks();
clearProfileCache();
});
it('should throw an error if the profile file does not exist', async () => {
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory'));
it('should throw an error if rxresumeBaseResumeId is not configured', async () => {
vi.mocked(getSetting).mockResolvedValue(null);
await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory');
await expect(getProfile()).rejects.toThrow(
'Base resume not configured. Please select a base resume from your RxResume account in Settings.'
);
});
it('should throw an error if the profile file is invalid JSON', async () => {
vi.mocked(readFile).mockResolvedValue('invalid json');
it('should fetch profile from RxResume v4 API when configured', async () => {
const mockResumeData = { basics: { name: 'Test User' } };
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: mockResumeData
} as any);
await expect(getProfile('/invalid/json.json', true)).rejects.toThrow();
const profile = await getProfile();
expect(getSetting).toHaveBeenCalledWith('rxresumeBaseResumeId');
expect(getResume).toHaveBeenCalledWith('test-resume-id');
expect(profile).toEqual(mockResumeData);
});
it('should cache the profile and not refetch on subsequent calls', async () => {
const mockResumeData = { basics: { name: 'Test User' } };
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: mockResumeData
} as any);
await getProfile();
await getProfile();
// getSetting is called each time to check resumeId
expect(getSetting).toHaveBeenCalledTimes(2);
// But getResume should only be called once due to caching
expect(getResume).toHaveBeenCalledTimes(1);
});
it('should refetch when forceRefresh is true', async () => {
const mockResumeData = { basics: { name: 'Test User' } };
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: mockResumeData
} as any);
await getProfile();
await getProfile(true);
expect(getResume).toHaveBeenCalledTimes(2);
});
it('should throw user-friendly error on credential issues', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
await expect(getProfile()).rejects.toThrow(
'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.'
);
});
it('should throw error if resume data is empty', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: null
} as any);
await expect(getProfile()).rejects.toThrow('Resume data is empty or invalid');
});
});

View File

@ -1,33 +1,56 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
/**
* Profile service - fetches resume data from RxResume v4 API.
*
* The rxresumeBaseResumeId setting is REQUIRED for the app to function.
* There is no local file fallback.
*/
import { getDataDir } from '../config/dataDir.js';
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json');
import { getSetting } from '../repositories/settings.js';
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
let cachedProfile: any = null;
let cachedProfilePath: string | null = null;
let cachedResumeId: string | null = null;
/**
* Get the base resume profile from resume.json.
* Caches the result since it doesn't change often.
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
* @param forceRefresh Force reload from disk.
* Get the base resume profile from RxResume v4 API.
*
* Requires rxresumeBaseResumeId to be configured in settings.
* Results are cached until clearProfileCache() is called.
*
* @param forceRefresh Force reload from API.
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
*/
export async function getProfile(profilePath?: string, forceRefresh = false): Promise<any> {
const targetPath = profilePath || DEFAULT_PROFILE_PATH;
export async function getProfile(forceRefresh = false): Promise<any> {
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) {
if (!rxresumeBaseResumeId) {
throw new Error(
'Base resume not configured. Please select a base resume from your RxResume account in Settings.'
);
}
// Return cached profile if valid
if (cachedProfile && cachedResumeId === rxresumeBaseResumeId && !forceRefresh) {
return cachedProfile;
}
try {
const content = await readFile(targetPath, 'utf-8');
cachedProfile = JSON.parse(content);
cachedProfilePath = targetPath;
console.log(`📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`);
const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== 'object') {
throw new Error('Resume data is empty or invalid');
}
cachedProfile = resume.data;
cachedResumeId = rxresumeBaseResumeId;
console.log(`✅ Profile loaded from RxResume v4 API`);
return cachedProfile;
} catch (error) {
console.error(`❌ Failed to load profile from ${targetPath}:`, error);
if (error instanceof RxResumeCredentialsError) {
throw new Error('RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.');
}
console.error(`❌ Failed to load profile from RxResume v4 API:`, error);
throw error;
}
}
@ -45,4 +68,5 @@ export async function getPersonName(): Promise<string> {
*/
export function clearProfileCache(): void {
cachedProfile = null;
cachedResumeId = null;
}

View File

@ -1,15 +1,6 @@
import { readFile } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
import { getProfile, DEFAULT_PROFILE_PATH } from './profile.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
export function extractProjectsFromProfile(profile: unknown): {
catalog: ResumeProjectCatalogItem[];
selectionItems: ResumeProjectSelectionItem[];
@ -155,4 +146,3 @@ function uniqueStrings(values: string[]): string[] {
}
export type { ResumeProjectSelectionItem };

View File

@ -222,6 +222,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ accessToken: 'mock-token-123' }),
});
vi.stubGlobal('fetch', mockFetch);
@ -235,6 +236,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ data: { accessToken: 'nested-token' } }),
});
vi.stubGlobal('fetch', mockFetch);
@ -248,6 +250,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ token: 'alt-token-field' }),
});
vi.stubGlobal('fetch', mockFetch);
@ -257,6 +260,43 @@ describe('RxResumeClient', () => {
expect(token).toBe('alt-token-field');
});
it('extracts token from set-cookie header when missing from body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn().mockReturnValue(null),
getSetCookie: vi
.fn()
.mockReturnValue(['Authentication=cookie-token; Path=/; HttpOnly']),
},
json: async () => ({}),
});
vi.stubGlobal('fetch', mockFetch);
const token = await client.login('test@example.com', 'password123');
expect(token).toBe('cookie-token');
});
it('extracts token from set-cookie string header fallback', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi
.fn()
.mockReturnValue('Authentication=string-token; Path=/; HttpOnly'),
},
json: async () => ({}),
});
vi.stubGlobal('fetch', mockFetch);
const token = await client.login('test@example.com', 'password123');
expect(token).toBe('string-token');
});
it('throws error on login failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
@ -274,6 +314,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ user: { id: '123' } }),
});
vi.stubGlobal('fetch', mockFetch);
@ -489,6 +530,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ accessToken: 'token' }),
});
vi.stubGlobal('fetch', mockFetch);

View File

@ -1,11 +1,49 @@
// rxresume-client.ts
// Minimal client for https://v4.rxresu.me
// Currently only verifyCredentials is in use; other methods are reserved for future use.
//
// NOTE (critical): Credentials should never be hardcoded or logged.
// Low-level HTTP client for the RxResume v4 API.
// - Handles login, token caching, and cookie-based auth.
// - Used by rxresume-v4.ts to provide a higher-level service surface.
// - The v5 client should be a drop-in replacement in the future.
import type { ResumeData } from '../../shared/rxresume-schema.js';
type AnyObj = Record<string, unknown>;
const TOKEN_COOKIE_NAMES = [
'accessToken',
'access_token',
'token',
'authToken',
'auth_token',
'Authentication',
'Refresh',
];
function extractTokenFromCookies(rawCookies: string | string[] | null): string | null {
if (!rawCookies) return null;
const combined = Array.isArray(rawCookies) ? rawCookies.join('; ') : rawCookies;
for (const name of TOKEN_COOKIE_NAMES) {
const match = new RegExp(`${name}=([^;]+)`).exec(combined);
if (match?.[1]) return match[1];
}
return null;
}
function buildAuthHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
Cookie: `Authentication=${token}`,
};
}
export type RxResumeResume = {
id: string;
name: string;
title: string;
slug?: string;
data?: ResumeData;
[key: string]: unknown;
};
export type VerifyResult =
| { ok: true }
| {
@ -17,8 +55,113 @@ export type VerifyResult =
details?: unknown;
};
interface CachedToken {
token: string;
expiresAt: number; // Unix timestamp
}
// Token cache: key is hash of baseURL + identifier
const tokenCache = new Map<string, CachedToken>();
// Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour)
const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000;
export class RxResumeClient {
constructor(private readonly baseURL = 'https://v4.rxresu.me') { }
private readonly tokenTtlMs: number;
constructor(
private readonly baseURL = 'https://v4.rxresu.me',
options?: { tokenTtlMs?: number }
) {
this.tokenTtlMs = options?.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
}
/**
* Generate a cache key for token storage.
* Uses a simple hash of baseURL + identifier.
*/
private getCacheKey(identifier: string): string {
return `${this.baseURL}:${identifier}`;
}
/**
* Get a valid auth token, using cached token if available and not expired.
* This is the preferred way to get a token for API calls.
*/
async getToken(identifier: string, password: string): Promise<string> {
const cacheKey = this.getCacheKey(identifier);
const cached = tokenCache.get(cacheKey);
// Return cached token if it exists and hasn't expired
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
// Login to get a new token
const token = await this.login(identifier, password);
// Cache the token
tokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + this.tokenTtlMs,
});
return token;
}
/**
* Clear cached token for a specific identifier.
* Useful when a token becomes invalid (e.g., 401 response).
*/
clearCachedToken(identifier: string): void {
const cacheKey = this.getCacheKey(identifier);
tokenCache.delete(cacheKey);
}
/**
* Clear all cached tokens.
*/
static clearAllCachedTokens(): void {
tokenCache.clear();
}
/**
* Execute an API operation with automatic token refresh on 401.
* If the operation fails with a 401, clears the cached token, gets a new one, and retries once.
*
* @param identifier - The user identifier (email)
* @param password - The user password
* @param operation - A function that takes a token and performs the API call
* @returns The result of the operation
*/
async withAutoRefresh<T>(
identifier: string,
password: string,
operation: (token: string) => Promise<T>
): Promise<T> {
const token = await this.getToken(identifier, password);
try {
return await operation(token);
} catch (error) {
// Check if this is a 401 error
const message = error instanceof Error ? error.message : '';
const isAuthError =
/HTTP\s*401/i.test(message) ||
/Unauthorized/i.test(message) ||
/Unauthenticated/i.test(message);
if (isAuthError) {
// Clear the cached token and retry with a fresh one
this.clearCachedToken(identifier);
const freshToken = await this.getToken(identifier, password);
return await operation(freshToken);
}
// Re-throw non-401 errors
throw error;
}
}
/**
* Verify a username/password combo WITHOUT persisting a logged-in session.
@ -98,13 +241,19 @@ export class RxResumeClient {
const data = (await res.json()) as AnyObj;
// The API may return the token in different ways
const token =
let token =
data?.accessToken ??
data?.access_token ??
data?.token ??
(data?.data as AnyObj)?.accessToken ??
(data?.data as AnyObj)?.token;
if (!token) {
const setCookieHeader = res.headers.get('set-cookie');
const setCookieArray = (res.headers as any).getSetCookie?.() as string[] | undefined;
token = extractTokenFromCookies(setCookieArray ?? setCookieHeader);
}
if (!token || typeof token !== 'string') {
throw new Error(
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}`
@ -117,15 +266,22 @@ export class RxResumeClient {
/**
* POST /api/resume/import
*/
async create(resumeData: unknown, token: string): Promise<string> {
async create(
resumeData: unknown,
token: string,
options?: { title?: string; slug?: string }
): Promise<string> {
const payload: AnyObj = { data: resumeData };
if (options?.title) payload.title = options.title;
if (options?.slug) payload.slug = options.slug;
const res = await fetch(`${this.baseURL}/api/resume/import`, {
method: 'POST',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...buildAuthHeaders(token),
},
body: JSON.stringify({ data: resumeData }),
body: JSON.stringify(payload),
});
if (!res.ok) {
@ -162,7 +318,7 @@ export class RxResumeClient {
method: 'GET',
headers: {
Accept: 'application/json, text/plain, */*',
Authorization: `Bearer ${token}`,
...buildAuthHeaders(token),
},
}
);
@ -200,7 +356,7 @@ export class RxResumeClient {
method: 'DELETE',
headers: {
Accept: 'application/json, text/plain, */*',
Authorization: `Bearer ${token}`,
...buildAuthHeaders(token),
},
}
);
@ -210,4 +366,68 @@ export class RxResumeClient {
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
}
}
private normalizeResume(raw: AnyObj): RxResumeResume {
const id = typeof raw.id === 'string' ? raw.id : '';
const title = typeof raw.title === 'string'
? raw.title
: typeof raw.name === 'string'
? raw.name
: 'Untitled';
const name = typeof raw.name === 'string' ? raw.name : title;
const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
const data = raw.data && typeof raw.data === 'object' ? (raw.data as ResumeData) : undefined;
return {
...raw,
id,
title,
name,
slug,
data,
};
}
/**
* GET /api/resume
* List all resumes for the authenticated user.
*/
async list(token: string): Promise<RxResumeResume[]> {
const res = await fetch(`${this.baseURL}/api/resume`, {
method: 'GET',
headers: {
Accept: 'application/json, text/plain, */*',
...buildAuthHeaders(token),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`List resumes failed: HTTP ${res.status} ${text}`);
}
const data = (await res.json()) as AnyObj | AnyObj[];
// API may return array directly or wrapped in data/resumes
const resumes = Array.isArray(data)
? data
: (data?.data as AnyObj[]) ?? (data?.resumes as AnyObj[]) ?? [];
return resumes
.filter((resume) => resume && typeof resume === 'object')
.map((resume) => this.normalizeResume(resume as AnyObj));
}
/**
* GET /api/resume
* Fetch a single resume by ID (via list filtering).
*/
async get(resumeId: string, token: string): Promise<RxResumeResume> {
const resumes = await this.list(token);
const resume = resumes.find((item) => item.id === resumeId);
if (!resume) {
throw new Error(`Resume not found: ${resumeId}`);
}
return resume;
}
}

View File

@ -0,0 +1,105 @@
// rxresume-v4.ts
// Service wrapper around the v4 client that mirrors the v5 helper API.
// - Pulls credentials from env/settings.
// - Validates resume payloads.
// - Keeps the rest of the app v5-ready (swap imports later).
import { resumeDataSchema } from '../../shared/rxresume-schema.js';
import type { ResumeData } from '../../shared/rxresume-schema.js';
import { RxResumeClient, type RxResumeResume } from './rxresume-client.js';
import { getSetting } from '../repositories/settings.js';
export type RxResumeCredentials = {
email: string;
password: string;
baseUrl: string;
};
export type RxResumeImportPayload = {
name?: string;
slug?: string;
data: ResumeData;
};
export class RxResumeCredentialsError extends Error {
constructor() {
super(
'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in environment or settings.'
);
this.name = 'RxResumeCredentialsError';
}
}
async function resolveRxResumeCredentials(
override?: Partial<RxResumeCredentials>
): Promise<RxResumeCredentials> {
const baseUrlRaw = override?.baseUrl ?? process.env.RXRESUME_URL ?? 'https://v4.rxresu.me';
const baseUrl = baseUrlRaw.trim() || 'https://v4.rxresu.me';
const overrideEmail = override?.email?.trim() ?? '';
const overridePassword = override?.password?.trim() ?? '';
let email = overrideEmail || process.env.RXRESUME_EMAIL || '';
let password = overridePassword || process.env.RXRESUME_PASSWORD || '';
if (!email) {
email = (await getSetting('rxresumeEmail')) || '';
}
if (!password) {
password = (await getSetting('rxresumePassword')) || '';
}
if (!email || !password) {
throw new RxResumeCredentialsError();
}
return { email, password, baseUrl };
}
async function withRxResumeClient<T>(
override: Partial<RxResumeCredentials> | undefined,
operation: (client: RxResumeClient, token: string) => Promise<T>
): Promise<T> {
const { email, password, baseUrl } = await resolveRxResumeCredentials(override);
const client = new RxResumeClient(baseUrl);
return client.withAutoRefresh(email, password, (token) => operation(client, token));
}
export async function listResumes(
override?: Partial<RxResumeCredentials>
): Promise<RxResumeResume[]> {
return withRxResumeClient(override, (client, token) => client.list(token));
}
export async function getResume(
resumeId: string,
override?: Partial<RxResumeCredentials>
): Promise<RxResumeResume> {
return withRxResumeClient(override, (client, token) => client.get(resumeId, token));
}
export async function importResume(
payload: RxResumeImportPayload,
override?: Partial<RxResumeCredentials>
): Promise<string> {
const data = resumeDataSchema.parse(payload.data);
const title = payload.name?.trim() || undefined;
const slug = payload.slug?.trim() || undefined;
return withRxResumeClient(override, (client, token) =>
client.create(data, token, { title, slug })
);
}
export async function deleteResume(
resumeId: string,
override?: Partial<RxResumeCredentials>
): Promise<void> {
return withRxResumeClient(override, (client, token) => client.delete(resumeId, token));
}
export async function exportResumePdf(
resumeId: string,
override?: Partial<RxResumeCredentials>
): Promise<string> {
return withRxResumeClient(override, (client, token) => client.print(resumeId, token));
}

View File

@ -0,0 +1,177 @@
// rxresume-v5.ts
// Future-facing v5/OpenAPI implementation that uses API keys.
// - Kept alongside v4 files so we can swap imports when v5 is ready.
// - Uses RXRESUME_API_KEY and /api/openapi endpoints.
//
// NOTE: Not currently wired in; keep for migration.
import { resumeDataSchema } from "../../shared/rxresume-schema.js";
export interface RxResumeResponse {
id: string;
name: string;
slug: string;
data: any;
[key: string]: any;
}
/**
* Temporary helper to execute a fetch request with multiple API keys if in development.
* THIS FUNCTION IS TEMPORARY AND WILL BE REMOVED.
*/
// Cache for last working key index (temporary, part of dev-only logic)
let lastWorkingKeyIndex = 0;
async function executeWithKeyRetries(url: string, options: RequestInit): Promise<any> {
const rawApiKey = process.env.RXRESUME_API_KEY;
if (!rawApiKey) {
throw new Error('RXRESUME_API_KEY not configured in environment');
}
const isDev = process.env.NODE_ENV !== 'production';
const apiKeys = (isDev && rawApiKey.includes(','))
? rawApiKey.split(',').map(k => k.trim())
: [rawApiKey];
// Start from the last working key index
for (let attempt = 0; attempt < apiKeys.length; attempt++) {
const i = (lastWorkingKeyIndex + attempt) % apiKeys.length;
const apiKey = apiKeys[i];
try {
const headers = {
'x-api-key': apiKey,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
} as Record<string, string>;
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`;
// ONLY retry/rotation on 401 Unauthorized
if (response.status === 401 && apiKeys.length > 1 && attempt < apiKeys.length - 1) {
console.warn(`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`);
continue;
}
throw new Error(errorMsg);
}
// Success! Cache this key index for future requests
lastWorkingKeyIndex = i;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text();
} catch (error) {
// If it was already handled by the 401 check above, it won't reach here
// because of the 'continue'. This catch is for network errors or unexpected throw.
throw error;
}
}
// Unmissable error block if all keys fail
if (apiKeys.length > 1) {
console.error(`
################################################################################
# #
# ALL REACTIVE RESUME API KEYS FAILED (${apiKeys.length} keys attempted) #
# Please check your .env configuration. #
# #
################################################################################
`);
}
throw new Error('All Reactive Resume API keys failed.');
}
/**
* Generic fetch helper for Reactive Resume API
*/
export async function fetchRxResume(path: string, options: RequestInit = {}): Promise<any> {
const baseUrl = process.env.RXRESUME_URL || 'https://rxresu.me';
let cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// Handle cases where the base URL already includes /api or /api/openapi
if (cleanBaseUrl.endsWith('/api/openapi')) {
cleanBaseUrl = cleanBaseUrl.slice(0, -12);
} else if (cleanBaseUrl.endsWith('/api')) {
cleanBaseUrl = cleanBaseUrl.slice(0, -4);
}
const url = `${cleanBaseUrl}/api/openapi${path}`;
return executeWithKeyRetries(url, options);
}
/**
* Fetch a resume by its ID.
*/
export async function getResume(id: string): Promise<RxResumeResponse> {
return fetchRxResume(`/resume/${id}`);
}
/**
* Import a resume.
*/
export async function importResume(payload: { name: string; slug: string; data: any }): Promise<string> {
// Validate data against schema before sending
try {
payload.data = resumeDataSchema.parse(payload.data);
} catch (error) {
console.error("❌ Resume data validation failed:", error);
throw error;
}
// DEBUG: Save payload to file for debugging (temporary)
try {
const fs = await import('fs/promises');
const path = await import('path');
const debugDir = path.join(process.cwd(), 'debug');
await fs.mkdir(debugDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = path.join(debugDir, `rxresume-import-${timestamp}.json`);
await fs.writeFile(filename, JSON.stringify(payload, null, 2), 'utf-8');
console.log(`📝 DEBUG: Saved import payload to ${filename}`);
} catch (debugErr) {
console.warn('⚠️ Could not save debug file:', debugErr);
}
const result = await fetchRxResume('/resume/import', {
method: 'POST',
body: JSON.stringify(payload),
});
// Reactive Resume returns the full resume object on import in v4+, or just ID in v5.
return typeof result === 'string' ? result : result.id;
}
/**
* Delete a resume.
*/
export async function deleteResume(id: string): Promise<void> {
await fetchRxResume(`/resume/${id}`, { method: 'DELETE' });
}
/**
* Export a resume as PDF. Returns the URL.
*/
export async function exportResumePdf(id: string): Promise<string> {
const result = await fetchRxResume(`/printer/resume/${id}/pdf`);
return result.url;
}
/**
* List all resumes.
* According to official OpenAPI spec, the endpoint is /resume/list
*/
export async function listResumes(): Promise<{ id: string; name: string }[]> {
return fetchRxResume('/resume/list');
}

View File

@ -3,19 +3,38 @@ import * as settingsRepo from '@server/repositories/settings.js';
import { getEnvSettingsData } from './envSettings.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
import { getProfile } from './profile.js';
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
/**
* Get the effective app settings, combining environment variables and database overrides.
*/
export async function getEffectiveSettings(): Promise<AppSettings> {
// Parallelize slow operations
const [overrides, profile] = await Promise.all([
settingsRepo.getAllSettings(),
getProfile().catch((error) => {
const overrides = await settingsRepo.getAllSettings();
const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null;
let profile: Record<string, unknown> = {};
if (rxresumeBaseResumeId) {
try {
const resume = await getResume(rxresumeBaseResumeId);
if (resume.data && typeof resume.data === 'object') {
profile = resume.data as Record<string, unknown>;
}
} catch (error) {
if (error instanceof RxResumeCredentialsError) {
console.warn('RxResume credentials missing while loading base resume from settings.');
} else {
console.warn('Failed to load RxResume base resume for settings:', error);
}
}
}
if (Object.keys(profile).length === 0) {
profile = await getProfile().catch((error) => {
console.warn('Failed to load base resume profile for settings:', error);
return {};
}),
]);
});
}
const envSettings = await getEnvSettingsData(overrides);
@ -114,6 +133,7 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
...resumeProjectsData,
rxresumeBaseResumeId,
ukvisajobsMaxJobs,
defaultUkvisajobsMaxJobs,
overrideUkvisajobsMaxJobs,

View File

@ -51,7 +51,7 @@ describe('Tailoring Flow', () => {
skills: ['React', 'TypeScript', 'Vitest']
}),
'Senior TypeScript Developer', // Original JD
expect.any(String), // Profile path
undefined, // Deprecated profile path
'project-a,project-c' // The manually selected projects
);
});
@ -78,7 +78,7 @@ describe('Tailoring Flow', () => {
skills: []
}),
'Junior Java Developer',
expect.any(String),
undefined, // Deprecated profile path
undefined // No projects selected
);
});

View File

@ -14,6 +14,7 @@ export const updateSettingsSchema = z.object({
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
resumeProjects: resumeProjectsSchema.nullable().optional(),
rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),

View File

@ -174,7 +174,6 @@ export interface PipelineConfig {
topN: number; // Number of top jobs to process
minSuitabilityScore: number; // Minimum score to auto-process
sources: JobSource[]; // Job sources to crawl
profilePath: string; // Path to profile JSON
outputDir: string; // Directory for generated PDFs
enableCrawling?: boolean;
enableScoring?: boolean;
@ -363,6 +362,7 @@ export interface AppSettings {
resumeProjects: ResumeProjectsSettings;
defaultResumeProjects: ResumeProjectsSettings;
overrideResumeProjects: ResumeProjectsSettings | null;
rxresumeBaseResumeId: string | null;
ukvisajobsMaxJobs: number;
defaultUkvisajobsMaxJobs: number;
overrideUkvisajobsMaxJobs: number | null;

View File

@ -10,6 +10,7 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
exclude: ['node_modules/**', 'dist/**'],
},
resolve: {
alias: {

View File

@ -1,8 +0,0 @@
# Temp JSON files (used by orchestrator)
temp_*.json
# Python virtual environment
.venv/
# Generated resumes
resumes/

View File

@ -1,362 +0,0 @@
{
"basics": {
"url": {
"href": "https://dakheera47.com/",
"label": "https://dakheera47.com/"
},
"name": "Shaheer Sarfaraz",
"email": "shaheer30sarfaraz@gmail.com",
"phone": "+44 7359 501592",
"picture": {
"url": "",
"size": 120,
"effects": {
"border": false,
"hidden": false,
"grayscale": false
},
"aspectRatio": 1,
"borderRadius": 0
},
"headline": "Frontend Software Engineer (React/TypeScript) · Autodesk Intern",
"location": "Blackpool, United Kingdom",
"customFields": []
},
"metadata": {
"css": {
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {
"format": "a4",
"margin": 34,
"options": {
"breakLine": false,
"pageNumbers": false
}
},
"notes": "",
"theme": {
"text": "#000000",
"primary": "#475569",
"background": "#ffffff"
},
"layout": [
[
[
"summary",
"profiles",
"experience",
"projects",
"education"
],
[
"skills",
"languages"
]
]
],
"template": "onyx",
"typography": {
"font": {
"size": 13,
"family": "IBM Plex Sans",
"subset": "latin",
"variants": [
"regular"
]
},
"hideIcons": false,
"lineHeight": 1.75,
"underlineLinks": true
}
},
"sections": {
"awards": {
"id": "awards",
"name": "Awards",
"items": [],
"columns": 1,
"visible": true,
"separateLinks": true
},
"custom": {},
"skills": {
"id": "skills",
"name": "Skills",
"items": [
{
"id": "jfgzfcwcg65k9gemuxlfe9m3",
"name": "Frontend",
"level": 0,
"visible": true,
"keywords": [
"React",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Redux",
"Astro",
"GraphQL",
"Webpack"
],
"description": ""
},
{
"id": "sk3957foopxir2hw4xzxqahh",
"name": "Backend & Tools",
"level": 0,
"visible": true,
"keywords": [
"Node.js",
"Express",
"Python (FastAPI)",
"PostgreSQL",
"MongoDB",
"Docker",
"AWS (S3)",
"Git/GitHub",
"Cypress",
"Jest"
],
"description": ""
}
],
"columns": 2,
"visible": true,
"separateLinks": true
},
"summary": {
"id": "summary",
"name": "Summary",
"columns": 1,
"content": "<p>Frontend Software Engineer with 1 year of production experience at Autodesk and a First-Class CS Degree. Specialist in modernizing legacy React/TypeScript codebases, optimizing CI/CD pipelines, and building scalable UI infrastructure.</p>",
"visible": true,
"separateLinks": true
},
"profiles": {
"id": "profiles",
"name": "Profiles",
"items": [
{
"id": "ukl0uecvzkgm27mlye0wazlb",
"url": {
"href": "https://github.com/DaKheera47",
"label": ""
},
"icon": "github",
"network": "GitHub",
"visible": true,
"username": "DaKheera47"
},
{
"id": "cnbk5f0aeqvhx69ebk7hktwd",
"url": {
"href": "https://www.linkedin.com/in/ssarfaraz30/",
"label": ""
},
"icon": "linkedin",
"network": "LinkedIn",
"visible": true,
"username": "ssarfaraz30"
}
],
"columns": 2,
"visible": true,
"separateLinks": true
},
"projects": {
"id": "projects",
"name": "Projects",
"items": [
{
"id": "i2t6epmx5v7s0d8rqtxsigp3",
"url": {
"href": "https://lifting.dakheera47.com/",
"label": ""
},
"date": "September 2025 - Present",
"name": "Strong Statistics (Open Source)",
"summary": "<ul><li><p><strong>Engineered a self-hosted analytics platform</strong> using FastAPI and Docker, enabling users to regain full data sovereignty from proprietary fitness apps.</p></li><li><p><strong>Maintained active open-source repo</strong>, triaging issues and merging PRs from global contributors to improve data visualization features.</p></li></ul>",
"visible": true,
"keywords": [],
"description": "FastAPI, Next.js, Docker, SQLite"
},
{
"id": "rw3x7tapntrt877rbl4pnxz7",
"url": {
"href": "https://exploranium.vercel.app/dashboard",
"label": ""
},
"date": "Oct 45, 2025",
"name": "NASA Space Apps Challenge",
"summary": "<ul><li><p><strong>Built a real-time analytics dashboard</strong> in 48 hours, integrating backend services to visualize Kepler/TESS catalogs for ML scoring.</p></li><li><p><strong>Reduced data-prep time by 60%</strong> by designing a harmonization pipeline that standardized multi-mission astronomical datasets.</p></li></ul>",
"visible": false,
"keywords": [],
"description": "Hackathon Winner"
},
{
"id": "tcecguinuctb8mu2xqrn97m8",
"url": {
"href": "https://www.mumtazurdu.com/",
"label": ""
},
"date": "July 2022",
"name": "Mumtaz Urdu",
"summary": "<ul><li><p><strong>Scaled a Next.js educational platform</strong> to support thousands of monthly users, utilizing <strong>MongoDB aggregation pipelines</strong> for sub-second data processing.</p></li><li><p><strong>Maximized user retention</strong> by engineering a Progressive Web App (PWA) with offline caching strategies, delivering a native-app-like mobile experience.</p></li></ul>",
"visible": true,
"keywords": [],
"description": "Next.js, MongoDB, AWS S3"
},
{
"id": "fwxrq682hqrj1y76rmziqrbk",
"url": {
"href": "http://www.ims-auh.com",
"label": ""
},
"date": "May 2022 - Ongoing",
"name": "Indus Marine Services",
"summary": "<ul><li><p><strong>Architected a digital induction system</strong> using Node.js and EJS, automating compliance testing and certification issuance for marine staff.</p></li></ul>",
"visible": true,
"keywords": [],
"description": "Node.js, Express, EJS"
}
],
"columns": 1,
"visible": true,
"separateLinks": true
},
"education": {
"id": "education",
"name": "Education",
"items": [
{
"id": "yo3p200zo45c6cdqc6a2vtt3",
"url": {
"href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc",
"label": ""
},
"area": "Preston, United Kingdom",
"date": "September 2022 to June 2026",
"score": "1st Class",
"summary": "<p>Relevant Modules: Web Applications, Algorithms & Data Structures, Software Engineering (Agile), Databases.</p>",
"visible": true,
"studyType": "BSc (Hons) Computer Science",
"institution": "University of Lancashire"
}
],
"columns": 1,
"visible": true,
"separateLinks": true
},
"interests": {
"id": "interests",
"name": "Interests",
"items": [],
"columns": 1,
"visible": false,
"separateLinks": true
},
"languages": {
"id": "languages",
"name": "Languages",
"items": [],
"columns": 1,
"visible": true,
"separateLinks": true
},
"volunteer": {
"id": "volunteer",
"name": "Volunteering",
"items": [],
"columns": 1,
"visible": false,
"separateLinks": true
},
"experience": {
"id": "experience",
"name": "Experience",
"items": [
{
"id": "ng9ui2azk7w4y8oyu8kazqeb",
"url": {
"href": "",
"label": ""
},
"date": "July 2024 - June 2025",
"company": "Autodesk",
"summary": "<ul><li><p><strong>Modernized a legacy 10-year-old React/TypeScript codebase</strong> (7k+ commits) by implementing <strong>Webpack Module Federation</strong>, enabling independent deployment of micro-frontends.</p></li><li><p><strong>Drove technical decision-making</strong> by authoring ADRs (Architectural Decision Records) for error handling standardization and <strong>Clash Data streaming</strong>, aligning platform-wide engineering practices.</p></li><li><p><strong>Secured release pipelines</strong> by resolving flaky Cypress E2E tests during 'Test Fests', directly preventing production regressions for major feature drops.</p></li></ul>",
"visible": true,
"location": "Hybrid (Sheffield Based)",
"position": "Software Engineering Intern"
},
{
"id": "lhw25d7gf32wgdfpsktf6e0x",
"url": {
"href": "https://promirage.com/",
"label": ""
},
"date": "December 2019 to Present",
"company": "Mirage",
"summary": "<ul><li><p><strong>Delivered 10+ production web applications</strong> for clients using Next.js, Tailwind, and Node.js, managing the full lifecycle from technical scoping to CI/CD deployment.</p></li><li><p><strong>Led a remote team of 4 developers</strong>, establishing code review standards and sprint workflows that ensured 100% on-time delivery for clients like Indus Marine.</p></li></ul>",
"visible": true,
"location": "",
"position": "Lead Full Stack Engineer (Contract)"
},
{
"id": "a1bg5d8gp8sulf91xzdcsiaq",
"url": {
"href": "",
"label": ""
},
"date": "Summer 2024",
"company": "Research and Knowledge Exchange Institute",
"summary": "<ul><li><p><strong>Engineered a React/Astro web app</strong> to approximate eye-tracking data, enabling low-cost HCI research for 10+ student participants.</p></li><li><p><strong>Automated data collection pipelines</strong> by building a Next.js Questionnaire Randomiser that generates per-student PDF reports, eliminating 10+ hours of manual data entry.</p></li></ul>",
"visible": true,
"location": "",
"position": "Undergraduate Research Intern (HCI & EdTech)"
},
{
"id": "k6zxqunkb225hbjso3c3vykk",
"url": {
"href": "",
"label": ""
},
"date": "July 2023 - July 2024",
"company": "University of Lancashire",
"summary": "<ul><li><p><strong>Mentored 10+ first-year students</strong> in full-stack development, facilitating weekly code reviews and technical workshops that improved pass rates.</p></li></ul>",
"visible": false,
"location": "Preston, UK",
"position": "Computing Student Mentor"
}
],
"columns": 1,
"visible": true,
"separateLinks": true
},
"references": {
"id": "references",
"name": "References",
"items": [],
"columns": 1,
"visible": false,
"separateLinks": true
},
"publications": {
"id": "publications",
"name": "Publications",
"items": [],
"columns": 1,
"visible": false,
"separateLinks": true
},
"certifications": {
"id": "certifications",
"name": "Certifications",
"items": [],
"columns": 1,
"visible": true,
"separateLinks": true
}
}
}

View File

@ -1,139 +0,0 @@
"""
Generate a tailored résumé summary using AI (OpenRouter API).
"""
import os
import json
import requests
import pyperclip
from dotenv import load_dotenv
def load_profile(path: str = "./base.json") -> dict:
"""Load the user's profile from a JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def load_job_description(from_clipboard: bool = True, path: str = None) -> str:
"""
Load the job description from clipboard or a file.
Args:
from_clipboard: If True, read from system clipboard
path: If from_clipboard is False, read from this file path
Returns:
The job description text
"""
if from_clipboard:
return pyperclip.paste().strip()
if path:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
raise ValueError("No job description source provided.")
def _build_prompt(profile: dict, jd: str) -> str:
"""Build the prompt for the AI model."""
return f"""
You are generating a tailored résumé summary for me.
Requirements:
- Use keywords found in the job description.
- Keep it concise but meaningful. Avoid fluff. Avoid long-winded text.
- Include just enough detail to feel real and grounded.
- Gently convey that I care about helping people and doing good work.
- Do NOT invent experience or skills I don't have.
- Maintain a warm, confident, human tone.
- Target THIS specific job directly, so use ATS keywords, while remaining natural.
- Use the profile to add context and details.
My profile (JSON fields merged):
{json.dumps(profile, indent=2)}
Job description:
{jd}
Write the résumé summary now.
"""
def _call_openrouter(prompt: str, model: str, api_key: str) -> str:
"""Call OpenRouter API to generate text."""
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "http://localhost",
"X-Title": "ResumeSummaryScript",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"plugins": [{"id": "response-healing"}],
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
raise RuntimeError(f"OpenRouter error {response.status_code}: {response.text}")
data = response.json()
return data["choices"][0]["message"]["content"]
def generate_resume_summary(
profile_path: str = "./base.json",
job_description: str = None,
from_clipboard: bool = True,
copy_to_clipboard: bool = True,
) -> str:
"""
Generate a tailored résumé summary using AI.
Uses the user's profile and a job description to generate a personalized
summary section for a résumé, targeting the specific job.
Args:
profile_path: Path to the profile JSON file
job_description: Job description text (if None, uses from_clipboard/path)
from_clipboard: If job_description is None, read JD from clipboard
copy_to_clipboard: If True, copy the generated summary to clipboard
Returns:
The generated résumé summary text
"""
load_dotenv()
api_key = os.getenv("OPENROUTER_API_KEY")
model = os.getenv("MODEL", "google/gemini-3-flash-preview")
if not api_key:
raise RuntimeError("Missing OPENROUTER_API_KEY in .env")
profile = load_profile(profile_path)
if job_description is None:
jd = load_job_description(from_clipboard=from_clipboard)
else:
jd = job_description
prompt = _build_prompt(profile, jd)
summary = _call_openrouter(prompt, model, api_key)
if copy_to_clipboard:
pyperclip.copy(summary)
return summary
if __name__ == "__main__":
summary = generate_resume_summary()
print("\n=== Generated Summary ===\n")
print(summary)
print("\n[Summary copied to clipboard]\n")

View File

@ -1,184 +0,0 @@
"""
Automate RXResume (rxresu.me) to import resume and export PDF using Playwright.
"""
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
# Configuration
RXRESUME_EMAIL = os.getenv("RXRESUME_EMAIL", "")
RXRESUME_PASSWORD = os.getenv("RXRESUME_PASSWORD", "")
BASE_DIR = Path(__file__).parent
# Allow override via environment variables (used by orchestrator)
_custom_json_path = os.getenv("RESUME_JSON_PATH")
RESUME_JSON_PATH = (
Path(_custom_json_path) if _custom_json_path else BASE_DIR / "base.json"
)
_custom_output_filename = os.getenv("OUTPUT_FILENAME")
OUTPUT_FILENAME = _custom_output_filename if _custom_output_filename else "resume.pdf"
# Output directory - can be overridden by orchestrator
_custom_output_dir = os.getenv("OUTPUT_DIR")
OUTPUT_DIR = Path(_custom_output_dir) if _custom_output_dir else BASE_DIR / "resumes"
def login(page):
"""Log in to RXResume."""
page.goto("https://v4.rxresu.me/auth/login")
page.fill('input[placeholder="john.doe@example.com"]', RXRESUME_EMAIL)
page.fill('input[type="password"]', RXRESUME_PASSWORD)
page.click('button:has-text("Sign in")')
page.wait_for_url("**/dashboard/resumes", timeout=15000)
page.click('button:has-text("List")')
def import_resume(page, json_path: Path):
"""Import a resume JSON file."""
# Log the JSON file size for debugging
try:
import json
with open(json_path, 'r') as f:
data = json.load(f)
print(f" 📋 JSON keys: {list(data.keys())}")
if 'basics' in data:
print(f" 📋 Headline: {data['basics'].get('headline', 'N/A')[:50]}...")
except Exception as e:
print(f" ⚠️ Could not read JSON for logging: {e}")
page.click('h4:has-text("Import")')
page.set_input_files('input[type="file"]', str(json_path))
page.click('button:has-text("Validate")')
# Wait for validation to complete - check for either success (Import button) or error
try:
# Wait for the Import button to become visible (validation succeeded)
page.wait_for_selector('button:has-text("Import"):not([disabled])', timeout=10000)
except Exception as e:
# Save debug files to errors folder (accessible outside Docker)
errors_dir = OUTPUT_DIR.parent / "errors"
errors_dir.mkdir(parents=True, exist_ok=True)
# Take a screenshot for debugging
try:
screenshot_path = errors_dir / f"debug_{json_path.stem}.png"
page.screenshot(path=str(screenshot_path))
print(f" 📸 Debug screenshot saved: {screenshot_path}")
except Exception as screenshot_err:
print(f" ⚠️ Could not save screenshot: {screenshot_err}")
# Copy the failed JSON to errors folder for inspection
try:
import shutil
failed_json_path = errors_dir / f"{json_path.stem}.json"
shutil.copy(str(json_path), str(failed_json_path))
print(f" 📋 Failed JSON saved: {failed_json_path}")
except Exception as copy_err:
print(f" ⚠️ Could not save failed JSON: {copy_err}")
# Check for validation error messages in the dialog
error_selectors = [
'text=/error|invalid|failed/i',
'[class*="error"]',
'[class*="destructive"]',
'.text-red-500',
'.text-destructive',
'[role="alert"]',
]
for selector in error_selectors:
error_element = page.query_selector(selector)
if error_element:
error_text = error_element.inner_text().strip()
if error_text:
print(f" ❌ RXResume validation error: {error_text}")
raise RuntimeError(f"RXResume validation failed: {error_text}")
# Log what's visible in the dialog for debugging
dialog = page.query_selector('[role="dialog"]')
if dialog:
dialog_text = dialog.inner_text()[:500]
print(f" 📋 Dialog content: {dialog_text}")
raise RuntimeError(f"Import button not found after validation (timeout): {e}")
page.click('button:has-text("Import")')
def navigate_to_top_resume(page):
"""Navigate to the first resume in the editor."""
if "/dashboard/resumes" not in page.url:
page.goto("https://v4.rxresu.me/dashboard/resumes")
page.wait_for_load_state("networkidle")
# wait a beat for the list to update
page.wait_for_timeout(1000)
page.click('span[data-state="closed"]:first-of-type div:first-of-type')
page.wait_for_url("**/builder/**", timeout=10000)
def export_pdf(page, output_path: Path) -> Path:
"""Export the resume as PDF."""
page.wait_for_timeout(1500) # Wait for builder to fully load
selector = "div.inline-flex.items-center.justify-center.rounded-full.bg-background.px-4.shadow-xl button:last-of-type"
with page.expect_download(timeout=30000) as download_info:
page.click(selector)
download = download_info.value
output_path.parent.mkdir(parents=True, exist_ok=True)
download.save_as(str(output_path))
return output_path
def generate_resume_pdf(
output_filename: str = None,
import_json: bool = True,
json_path: Path = None,
) -> Path:
"""
Import resume and export PDF.
Args:
output_filename: Name of the output PDF file (defaults to OUTPUT_FILENAME env var)
import_json: Whether to import a JSON file first (default True)
json_path: Path to JSON file (defaults to RESUME_JSON_PATH env var)
Returns:
Path to the generated PDF
"""
# Use environment-provided defaults
actual_filename = output_filename or OUTPUT_FILENAME
actual_json_path = json_path or RESUME_JSON_PATH
output_path = OUTPUT_DIR / actual_filename
print(f"📄 Generating PDF: {actual_filename}")
print(f" JSON source: {actual_json_path}")
with sync_playwright() as playwright:
browser = playwright.firefox.launch(headless=True)
context = browser.new_context()
page = context.new_page()
try:
login(page)
if import_json:
import_resume(page, actual_json_path)
navigate_to_top_resume(page)
export_pdf(page, output_path)
finally:
browser.close()
print(f"✅ PDF saved: {output_path}")
return output_path
if __name__ == "__main__":
# When run directly, use environment variables or defaults
pdf_path = generate_resume_pdf()
print(f"Done! PDF saved: {pdf_path}")