settings page can pull and save resume details from v4 api template
This commit is contained in:
@@ -247,6 +247,13 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
|||||||
return data.resumes;
|
return data.resumes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRxResumeProjects(resumeId: string): Promise<ResumeProjectCatalogItem[]> {
|
||||||
|
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
|
||||||
|
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`
|
||||||
|
);
|
||||||
|
return data.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Database API
|
// Database API
|
||||||
export async function clearDatabase(): Promise<{
|
export async function clearDatabase(): Promise<{
|
||||||
|
|||||||
@@ -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 { Check } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -13,17 +12,15 @@ import * as api from "@client/api"
|
|||||||
import { useSettings } from "@client/hooks/useSettings"
|
import { useSettings } from "@client/hooks/useSettings"
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||||
import type { ResumeProfile, ValidationResult } from "@shared/types"
|
import type { ValidationResult } from "@shared/types"
|
||||||
|
|
||||||
type ValidationState = ValidationResult & { checked: boolean }
|
type ValidationState = ValidationResult & { checked: boolean }
|
||||||
|
|
||||||
export const OnboardingGate: React.FC = () => {
|
export const OnboardingGate: React.FC = () => {
|
||||||
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
||||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
||||||
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
|
||||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
||||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
||||||
const [isValidatingResume, setIsValidatingResume] = useState(false)
|
|
||||||
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: null,
|
message: null,
|
||||||
@@ -34,34 +31,11 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
message: null,
|
message: null,
|
||||||
checked: false,
|
checked: false,
|
||||||
})
|
})
|
||||||
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
|
|
||||||
valid: false,
|
|
||||||
message: null,
|
|
||||||
checked: false,
|
|
||||||
})
|
|
||||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
||||||
|
|
||||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
||||||
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
||||||
const [rxresumePassword, setRxresumePassword] = 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 validateOpenrouter = useCallback(async (apiKey?: string) => {
|
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||||
setIsValidatingOpenrouter(true)
|
setIsValidatingOpenrouter(true)
|
||||||
@@ -98,10 +72,8 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
||||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
||||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
||||||
const hasBaseResume = resumeValidation.valid
|
|
||||||
|
|
||||||
const shouldOpen = Boolean(settings && !settingsLoading)
|
const shouldOpen = Boolean(settings && !settingsLoading)
|
||||||
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
|
&& !(openrouterValidation.valid && rxresumeValidation.valid)
|
||||||
|
|
||||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||||
@@ -127,14 +99,8 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
subtitle: "RxResume login",
|
subtitle: "RxResume login",
|
||||||
complete: rxresumeValidation.valid,
|
complete: rxresumeValidation.valid,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "resume",
|
|
||||||
label: "Resume JSON",
|
|
||||||
subtitle: "Upload your file",
|
|
||||||
complete: resumeValidation.valid,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid]
|
[openrouterValidation.valid, rxresumeValidation.valid]
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
|
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
|
||||||
@@ -151,7 +117,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
validateOpenrouter(),
|
validateOpenrouter(),
|
||||||
validateRxresume(),
|
validateRxresume(),
|
||||||
validateResume(),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const failed = results.find((result) => result.status === "rejected")
|
const failed = results.find((result) => result.status === "rejected")
|
||||||
@@ -160,13 +125,13 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
}
|
}
|
||||||
}, [settings, validateOpenrouter, validateRxresume, validateResume])
|
}, [settings, validateOpenrouter, validateRxresume])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings || settingsLoading) return
|
if (!settings || settingsLoading) return
|
||||||
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
|
if (openrouterValidation.checked || rxresumeValidation.checked) return
|
||||||
void runAllValidations()
|
void runAllValidations()
|
||||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
|
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, runAllValidations])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
||||||
@@ -254,58 +219,17 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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.")
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.uploadProfile(parsed)
|
|
||||||
await validateResume()
|
|
||||||
setResumeFile(null)
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
toast.success("Resume uploaded")
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Failed to upload resume"
|
|
||||||
toast.error(message)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setIsUploadingResume(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumeFileName = resumeFile?.name || ""
|
|
||||||
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
|
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
|
||||||
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
|
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
|
||||||
const completedSteps = steps.filter((step) => step.complete).length
|
const completedSteps = steps.filter((step) => step.complete).length
|
||||||
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
|
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
|
||||||
const canGoBack = stepIndex > 0
|
const canGoBack = stepIndex > 0
|
||||||
const primaryLabel = currentStep === "resume"
|
const primaryLabel = currentStep === "openrouter"
|
||||||
? (resumeValidation.valid ? "Finish" : "Upload and validate")
|
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||||
: currentStep === "openrouter"
|
: currentStep === "rxresume"
|
||||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||||
: currentStep === "rxresume"
|
: "Validate"
|
||||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
|
||||||
: "Validate"
|
|
||||||
|
|
||||||
const handlePrimaryAction = async () => {
|
const handlePrimaryAction = async () => {
|
||||||
if (!currentStep) return
|
if (!currentStep) return
|
||||||
@@ -317,13 +241,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
await handleSaveRxresume()
|
await handleSaveRxresume()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentStep === "resume") {
|
|
||||||
if (hasBaseResume) {
|
|
||||||
await handleRefresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await handleUploadResume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -348,7 +265,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-2">
|
||||||
{steps.map((step, index) => {
|
{steps.map((step, index) => {
|
||||||
const isActive = step.id === currentStep
|
const isActive = step.id === currentStep
|
||||||
const isComplete = step.complete
|
const isComplete = step.complete
|
||||||
@@ -439,30 +356,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="resume" 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>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||||||
import { PageHeader } from "@client/components/layout"
|
import { PageHeader } from "@client/components/layout"
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
import { Accordion } from "@/components/ui/accordion"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
|
||||||
import * as api from "@client/api"
|
import * as api from "@client/api"
|
||||||
import { arraysEqual } from "@/lib/utils"
|
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 { JobspySection } from "@client/pages/settings/components/JobspySection"
|
||||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
||||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
|
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 { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
|
||||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
||||||
|
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"
|
||||||
|
|
||||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||||
model: "",
|
model: "",
|
||||||
@@ -31,6 +31,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
pipelineWebhookUrl: "",
|
pipelineWebhookUrl: "",
|
||||||
jobCompleteWebhookUrl: "",
|
jobCompleteWebhookUrl: "",
|
||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
|
rxresumeBaseResumeId: null,
|
||||||
ukvisajobsMaxJobs: null,
|
ukvisajobsMaxJobs: null,
|
||||||
gradcrackerMaxJobsPerTerm: null,
|
gradcrackerMaxJobsPerTerm: null,
|
||||||
searchTerms: null,
|
searchTerms: null,
|
||||||
@@ -60,6 +61,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
pipelineWebhookUrl: null,
|
pipelineWebhookUrl: null,
|
||||||
jobCompleteWebhookUrl: null,
|
jobCompleteWebhookUrl: null,
|
||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
|
rxresumeBaseResumeId: null,
|
||||||
ukvisajobsMaxJobs: null,
|
ukvisajobsMaxJobs: null,
|
||||||
gradcrackerMaxJobsPerTerm: null,
|
gradcrackerMaxJobsPerTerm: null,
|
||||||
searchTerms: null,
|
searchTerms: null,
|
||||||
@@ -89,6 +91,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
||||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||||
resumeProjects: data.resumeProjects,
|
resumeProjects: data.resumeProjects,
|
||||||
|
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
||||||
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
||||||
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
||||||
searchTerms: data.overrideSearchTerms,
|
searchTerms: data.overrideSearchTerms,
|
||||||
@@ -139,6 +142,35 @@ const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
|||||||
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||||
isSameStringList(value, defaultValue) ? null : value ?? null
|
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 = (base.aiSelectableProjectIds.length
|
||||||
|
? 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[]) =>
|
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||||
isSameSortedStringList(value, defaultValue) ? null : value ?? null
|
isSameSortedStringList(value, defaultValue) ? null : value ?? null
|
||||||
|
|
||||||
@@ -230,6 +262,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
|
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>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
resolver: zodResolver(updateSettingsSchema),
|
resolver: zodResolver(updateSettingsSchema),
|
||||||
@@ -237,7 +272,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
defaultValues: DEFAULT_FORM_VALUES,
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
@@ -263,6 +310,58 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [reset])
|
}, [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
|
||||||
|
|
||||||
|
if (!rxResumeBaseResumeIdDraft) {
|
||||||
|
setRxResumeProjectsOverride(null)
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasRxResumeAccess) return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingRxResumeProjects(true)
|
||||||
|
api
|
||||||
|
.getRxResumeProjects(rxResumeBaseResumeIdDraft)
|
||||||
|
.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) 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
|
||||||
|
}
|
||||||
|
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
|
||||||
|
|
||||||
const derived = getDerivedSettings(settings)
|
const derived = getDerivedSettings(settings)
|
||||||
const {
|
const {
|
||||||
model,
|
model,
|
||||||
@@ -279,6 +378,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
maxProjectsTotal,
|
maxProjectsTotal,
|
||||||
} = derived
|
} = derived
|
||||||
|
|
||||||
|
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
|
||||||
|
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
|
||||||
|
|
||||||
const watchedValues = watch()
|
const watchedValues = watch()
|
||||||
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
||||||
|
|
||||||
@@ -357,6 +459,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
|
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
||||||
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
|
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
|
||||||
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
|
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
|
||||||
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
||||||
@@ -502,10 +605,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
<ResumeProjectsSection
|
<ReactiveResumeSection
|
||||||
profileProjects={profileProjects}
|
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||||
|
setRxResumeBaseResumeIdDraft={(value) => {
|
||||||
|
setRxResumeBaseResumeIdDraft(value)
|
||||||
|
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
|
||||||
|
}}
|
||||||
|
hasRxResumeAccess={hasRxResumeAccess}
|
||||||
|
profileProjects={effectiveProfileProjects}
|
||||||
lockedCount={lockedCount}
|
lockedCount={lockedCount}
|
||||||
maxProjectsTotal={maxProjectsTotal}
|
maxProjectsTotal={effectiveMaxProjectsTotal}
|
||||||
|
isProjectsLoading={isFetchingRxResumeProjects}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import React, { useEffect, useState } from "react"
|
import React, { useEffect, useState } from "react"
|
||||||
|
import { Controller, useFormContext } from "react-hook-form"
|
||||||
import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"
|
import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
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 * as api from "../../../api"
|
import * as api from "../../../api"
|
||||||
|
|
||||||
type ReactiveResumeSectionProps = {
|
type ReactiveResumeSectionProps = {
|
||||||
rxResumeBaseResumeIdDraft: string | null
|
rxResumeBaseResumeIdDraft: string | null
|
||||||
setRxResumeBaseResumeIdDraft: (value: string | null) => void
|
setRxResumeBaseResumeIdDraft: (value: string | null) => void
|
||||||
hasRxResumeApiKey: boolean
|
// True when v4 credentials or v5 API key are configured.
|
||||||
|
hasRxResumeAccess: boolean
|
||||||
|
profileProjects: ResumeProjectCatalogItem[]
|
||||||
|
lockedCount: number
|
||||||
|
maxProjectsTotal: number
|
||||||
|
isProjectsLoading: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
}
|
}
|
||||||
@@ -18,16 +31,21 @@ type ReactiveResumeSectionProps = {
|
|||||||
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||||
rxResumeBaseResumeIdDraft,
|
rxResumeBaseResumeIdDraft,
|
||||||
setRxResumeBaseResumeIdDraft,
|
setRxResumeBaseResumeIdDraft,
|
||||||
hasRxResumeApiKey,
|
hasRxResumeAccess,
|
||||||
|
profileProjects,
|
||||||
|
lockedCount,
|
||||||
|
maxProjectsTotal,
|
||||||
|
isProjectsLoading,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||||
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
|
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
|
||||||
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
|
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchResumes = async () => {
|
const fetchResumes = async () => {
|
||||||
if (!hasRxResumeApiKey) return
|
if (!hasRxResumeAccess) return
|
||||||
|
|
||||||
setIsFetchingResumes(true)
|
setIsFetchingResumes(true)
|
||||||
setFetchError(null)
|
setFetchError(null)
|
||||||
@@ -42,10 +60,10 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRxResumeApiKey) {
|
if (hasRxResumeAccess) {
|
||||||
fetchResumes()
|
fetchResumes()
|
||||||
}
|
}
|
||||||
}, [hasRxResumeApiKey])
|
}, [hasRxResumeAccess])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
||||||
@@ -54,21 +72,21 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!hasRxResumeApiKey ? (
|
{!hasRxResumeAccess ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>API Key Missing</AlertTitle>
|
<AlertTitle>RxResume Access Missing</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<code>RXRESUME_API_KEY</code> is not configured in the server environment. Please add it to your <code>.env</code> file.
|
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
|
<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" />
|
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<AlertTitle className="text-green-800 dark:text-green-300">API Key Configured</AlertTitle>
|
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle>
|
||||||
<AlertDescription className="text-green-700 dark:text-green-400">
|
<AlertDescription className="text-green-700 dark:text-green-400">
|
||||||
Reactive Resume API integration is active.
|
Reactive Resume access is active.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -112,9 +130,141 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground mt-2">
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
The selected resume will be used as a template for tailoring. A temporary copy will be created during generation and deleted afterwards.
|
The selected resume will be used as a template for tailoring.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@server/services/resumeProjects.js';
|
} from '@server/services/resumeProjects.js';
|
||||||
import { getProfile } from '@server/services/profile.js';
|
import { getProfile } from '@server/services/profile.js';
|
||||||
import { getEffectiveSettings } from '@server/services/settings.js';
|
import { getEffectiveSettings } from '@server/services/settings.js';
|
||||||
import { listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
|
import { getResume, listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
@@ -58,6 +58,10 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
|||||||
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
|
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('rxresumeBaseResumeId' in input) {
|
||||||
|
promises.push(settingsRepo.setSetting('rxresumeBaseResumeId', normalizeEnvInput(input.rxresumeBaseResumeId)));
|
||||||
|
}
|
||||||
|
|
||||||
if ('resumeProjects' in input) {
|
if ('resumeProjects' in input) {
|
||||||
const resumeProjects = input.resumeProjects ?? null;
|
const resumeProjects = input.resumeProjects ?? null;
|
||||||
|
|
||||||
@@ -65,13 +69,35 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
|||||||
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
||||||
} else {
|
} else {
|
||||||
promises.push((async () => {
|
promises.push((async () => {
|
||||||
const rawProfile = await getProfile();
|
const baseResumeId = 'rxresumeBaseResumeId' in input
|
||||||
|
? normalizeEnvInput(input.rxresumeBaseResumeId)
|
||||||
|
: await settingsRepo.getSetting('rxresumeBaseResumeId');
|
||||||
|
|
||||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
let profile: Record<string, unknown> = {};
|
||||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
|
||||||
|
if (baseResumeId) {
|
||||||
|
try {
|
||||||
|
const resume = await getResume(baseResumeId);
|
||||||
|
if (resume.data && typeof resume.data === 'object') {
|
||||||
|
profile = resume.data as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RxResumeCredentialsError) {
|
||||||
|
throw new Error('RxResume credentials missing while validating resume projects.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(profile).length === 0) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = rawProfile as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = rawProfile as Record<string, unknown>;
|
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
const allowed = new Set(catalog.map((p) => p.id));
|
const allowed = new Set(catalog.map((p) => p.id));
|
||||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||||
@@ -218,3 +244,30 @@ settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => {
|
|||||||
res.status(500).json({ success: false, error: 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type SettingKey = 'model'
|
|||||||
| 'pipelineWebhookUrl'
|
| 'pipelineWebhookUrl'
|
||||||
| 'jobCompleteWebhookUrl'
|
| 'jobCompleteWebhookUrl'
|
||||||
| 'resumeProjects'
|
| 'resumeProjects'
|
||||||
|
| 'rxresumeBaseResumeId'
|
||||||
| 'ukvisajobsMaxJobs'
|
| 'ukvisajobsMaxJobs'
|
||||||
| 'gradcrackerMaxJobsPerTerm'
|
| 'gradcrackerMaxJobsPerTerm'
|
||||||
| 'searchTerms'
|
| 'searchTerms'
|
||||||
|
|||||||
@@ -3,19 +3,38 @@ import * as settingsRepo from '@server/repositories/settings.js';
|
|||||||
import { getEnvSettingsData } from './envSettings.js';
|
import { getEnvSettingsData } from './envSettings.js';
|
||||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||||
import { getProfile } from './profile.js';
|
import { getProfile } from './profile.js';
|
||||||
|
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the effective app settings, combining environment variables and database overrides.
|
* Get the effective app settings, combining environment variables and database overrides.
|
||||||
*/
|
*/
|
||||||
export async function getEffectiveSettings(): Promise<AppSettings> {
|
export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||||
// Parallelize slow operations
|
const overrides = await settingsRepo.getAllSettings();
|
||||||
const [overrides, profile] = await Promise.all([
|
|
||||||
settingsRepo.getAllSettings(),
|
const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null;
|
||||||
getProfile().catch((error) => {
|
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);
|
console.warn('Failed to load base resume profile for settings:', error);
|
||||||
return {};
|
return {};
|
||||||
}),
|
});
|
||||||
]);
|
}
|
||||||
|
|
||||||
const envSettings = await getEnvSettingsData(overrides);
|
const envSettings = await getEnvSettingsData(overrides);
|
||||||
|
|
||||||
@@ -114,6 +133,7 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
defaultJobCompleteWebhookUrl,
|
defaultJobCompleteWebhookUrl,
|
||||||
overrideJobCompleteWebhookUrl,
|
overrideJobCompleteWebhookUrl,
|
||||||
...resumeProjectsData,
|
...resumeProjectsData,
|
||||||
|
rxresumeBaseResumeId,
|
||||||
ukvisajobsMaxJobs,
|
ukvisajobsMaxJobs,
|
||||||
defaultUkvisajobsMaxJobs,
|
defaultUkvisajobsMaxJobs,
|
||||||
overrideUkvisajobsMaxJobs,
|
overrideUkvisajobsMaxJobs,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const resumeProjectsSchema = z.object({
|
export const resumeProjectsSchema = z.object({
|
||||||
maxProjects: z.number().int().min(0).max(100),
|
maxProjects: z.number().int().min(1).max(100),
|
||||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ export const updateSettingsSchema = z.object({
|
|||||||
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||||
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||||
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
||||||
|
rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(),
|
||||||
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
|
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
|
||||||
gradcrackerMaxJobsPerTerm: 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(),
|
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export interface AppSettings {
|
|||||||
resumeProjects: ResumeProjectsSettings;
|
resumeProjects: ResumeProjectsSettings;
|
||||||
defaultResumeProjects: ResumeProjectsSettings;
|
defaultResumeProjects: ResumeProjectsSettings;
|
||||||
overrideResumeProjects: ResumeProjectsSettings | null;
|
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||||
|
rxresumeBaseResumeId: string | null;
|
||||||
ukvisajobsMaxJobs: number;
|
ukvisajobsMaxJobs: number;
|
||||||
defaultUkvisajobsMaxJobs: number;
|
defaultUkvisajobsMaxJobs: number;
|
||||||
overrideUkvisajobsMaxJobs: number | null;
|
overrideUkvisajobsMaxJobs: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user