API implementation for Reactive Resume
Merge pull request #10 from DaKheera47/reactive-resume-v5
This commit is contained in:
commit
88db086d86
68
orchestrator/package-lock.json
generated
68
orchestrator/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
59
orchestrator/src/components/ui/alert.tsx
Normal file
59
orchestrator/src/components/ui/alert.tsx
Normal 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 }
|
||||
159
orchestrator/src/components/ui/select.tsx
Normal file
159
orchestrator/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export type SettingKey = 'model'
|
||||
| 'pipelineWebhookUrl'
|
||||
| 'jobCompleteWebhookUrl'
|
||||
| 'resumeProjects'
|
||||
| 'rxresumeBaseResumeId'
|
||||
| 'ukvisajobsMaxJobs'
|
||||
| 'gradcrackerMaxJobsPerTerm'
|
||||
| 'searchTerms'
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
105
orchestrator/src/server/services/rxresume-v4.ts
Normal file
105
orchestrator/src/server/services/rxresume-v4.ts
Normal 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));
|
||||
}
|
||||
177
orchestrator/src/server/services/rxresume-v5.ts
Normal file
177
orchestrator/src/server/services/rxresume-v5.ts
Normal 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');
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -10,6 +10,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setupTests.ts',
|
||||
exclude: ['node_modules/**', 'dist/**'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
8
resume-generator/.gitignore
vendored
8
resume-generator/.gitignore
vendored
@ -1,8 +0,0 @@
|
||||
# Temp JSON files (used by orchestrator)
|
||||
temp_*.json
|
||||
|
||||
# Python virtual environment
|
||||
.venv/
|
||||
|
||||
# Generated resumes
|
||||
resumes/
|
||||
@ -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 4–5, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
@ -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}")
|
||||
Loading…
x
Reference in New Issue
Block a user