onboarding validation for each field
This commit is contained in:
parent
6955a77af8
commit
9c1252c7fd
@ -21,6 +21,7 @@ import type {
|
|||||||
VisaSponsor,
|
VisaSponsor,
|
||||||
ResumeProfile,
|
ResumeProfile,
|
||||||
ProfileStatusResponse,
|
ProfileStatusResponse,
|
||||||
|
ValidationResult,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { trackEvent } from "@/lib/analytics";
|
import { trackEvent } from "@/lib/analytics";
|
||||||
|
|
||||||
@ -190,6 +191,24 @@ export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ apiKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateResumeJson(): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>('/onboarding/validate/resume');
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSettings(update: {
|
export async function updateSettings(update: {
|
||||||
model?: string | null
|
model?: string | null
|
||||||
modelScorer?: string | null
|
modelScorer?: string | null
|
||||||
|
|||||||
@ -13,14 +13,32 @@ 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 { ProfileStatusResponse, ResumeProfile } from "@shared/types"
|
import type { ResumeProfile, ValidationResult } from "@shared/types"
|
||||||
|
|
||||||
|
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 [profileStatus, setProfileStatus] = useState<ProfileStatusResponse | null>(null)
|
|
||||||
const [isCheckingProfile, setIsCheckingProfile] = useState(false)
|
|
||||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
||||||
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
||||||
|
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
||||||
|
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
||||||
|
const [isValidatingResume, setIsValidatingResume] = useState(false)
|
||||||
|
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
||||||
|
valid: false,
|
||||||
|
message: null,
|
||||||
|
checked: false,
|
||||||
|
})
|
||||||
|
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
|
||||||
|
valid: false,
|
||||||
|
message: null,
|
||||||
|
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("")
|
||||||
@ -29,31 +47,62 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const [resumeFile, setResumeFile] = useState<File | null>(null)
|
const [resumeFile, setResumeFile] = useState<File | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const refreshProfileStatus = useCallback(async () => {
|
const validateResume = useCallback(async () => {
|
||||||
setIsCheckingProfile(true)
|
setIsValidatingResume(true)
|
||||||
try {
|
try {
|
||||||
const status = await api.getProfileStatus()
|
const result = await api.validateResumeJson()
|
||||||
setProfileStatus(status)
|
setResumeValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to check base resume"
|
const message = error instanceof Error ? error.message : "Resume validation failed"
|
||||||
setProfileStatus({ exists: false, error: message })
|
const result = { valid: false, message }
|
||||||
|
setResumeValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingProfile(false)
|
setIsValidatingResume(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||||
void refreshProfileStatus()
|
setIsValidatingOpenrouter(true)
|
||||||
}, [refreshProfileStatus])
|
try {
|
||||||
|
const result = await api.validateOpenrouter(apiKey)
|
||||||
|
setOpenrouterValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "OpenRouter validation failed"
|
||||||
|
const result = { valid: false, message }
|
||||||
|
setOpenrouterValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
setIsValidatingOpenrouter(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const validateRxresume = useCallback(async (email?: string, password?: string) => {
|
||||||
|
setIsValidatingRxresume(true)
|
||||||
|
try {
|
||||||
|
const result = await api.validateRxresume(email, password)
|
||||||
|
setRxresumeValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "RxResume validation failed"
|
||||||
|
const result = { valid: false, message }
|
||||||
|
setRxresumeValidation({ ...result, checked: true })
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
setIsValidatingRxresume(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
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 hasRxresumeCredentials = hasRxresumeEmail && hasRxresumePassword
|
const hasRxresumeCredentials = hasRxresumeEmail && hasRxresumePassword
|
||||||
const hasBaseResume = Boolean(profileStatus?.exists)
|
const hasBaseResume = resumeValidation.valid
|
||||||
|
|
||||||
const shouldOpen = Boolean(settings && profileStatus && !settingsLoading && !isCheckingProfile)
|
const shouldOpen = Boolean(settings && !settingsLoading)
|
||||||
&& !(hasOpenrouterKey && hasRxresumeCredentials && hasBaseResume)
|
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
|
||||||
|
|
||||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||||
@ -71,22 +120,22 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
id: "openrouter",
|
id: "openrouter",
|
||||||
label: "Connect AI",
|
label: "Connect AI",
|
||||||
subtitle: "OpenRouter key",
|
subtitle: "OpenRouter key",
|
||||||
complete: hasOpenrouterKey,
|
complete: openrouterValidation.valid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "rxresume",
|
id: "rxresume",
|
||||||
label: "PDF Export",
|
label: "PDF Export",
|
||||||
subtitle: "RxResume login",
|
subtitle: "RxResume login",
|
||||||
complete: hasRxresumeCredentials,
|
complete: rxresumeValidation.valid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "resume",
|
id: "resume",
|
||||||
label: "Resume JSON",
|
label: "Resume JSON",
|
||||||
subtitle: "Upload your file",
|
subtitle: "Upload your file",
|
||||||
complete: hasBaseResume,
|
complete: resumeValidation.valid,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[hasBaseResume, hasOpenrouterKey, hasRxresumeCredentials]
|
[openrouterValidation.valid, resumeValidation.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
|
||||||
@ -98,8 +147,30 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentStep, defaultStep, shouldOpen])
|
}, [currentStep, defaultStep, shouldOpen])
|
||||||
|
|
||||||
|
const runAllValidations = useCallback(async () => {
|
||||||
|
if (!settings) return
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
validateOpenrouter(),
|
||||||
|
validateRxresume(),
|
||||||
|
validateResume(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const failed = results.find((result) => result.status === "rejected")
|
||||||
|
if (failed) {
|
||||||
|
const reason = failed.status === "rejected" ? failed.reason : null
|
||||||
|
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
}, [settings, validateOpenrouter, validateRxresume, validateResume])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || settingsLoading) return
|
||||||
|
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
|
||||||
|
void runAllValidations()
|
||||||
|
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const results = await Promise.allSettled([refreshSettings(), refreshProfileStatus()])
|
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
||||||
const failed = results.find((result) => result.status === "rejected")
|
const failed = results.find((result) => result.status === "rejected")
|
||||||
if (failed) {
|
if (failed) {
|
||||||
const reason = failed.status === "rejected" ? failed.reason : null
|
const reason = failed.status === "rejected" ? failed.reason : null
|
||||||
@ -110,17 +181,25 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
||||||
const openrouterValue = openrouterApiKey.trim()
|
const openrouterValue = openrouterApiKey.trim()
|
||||||
if (hasOpenrouterKey && !openrouterValue) return true
|
if (!openrouterValue && !hasOpenrouterKey) {
|
||||||
if (!openrouterValue) {
|
|
||||||
toast.info("Add your OpenRouter API key to continue")
|
toast.info("Add your OpenRouter API key to continue")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingEnv(true)
|
const validation = await validateOpenrouter(openrouterValue || undefined)
|
||||||
await api.updateSettings({ openrouterApiKey: openrouterValue })
|
if (!validation.valid) {
|
||||||
await refreshSettings()
|
toast.error(validation.message || "OpenRouter validation failed")
|
||||||
setOpenrouterApiKey("")
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openrouterValue) {
|
||||||
|
setIsSavingEnv(true)
|
||||||
|
await api.updateSettings({ openrouterApiKey: openrouterValue })
|
||||||
|
await refreshSettings()
|
||||||
|
setOpenrouterApiKey("")
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("OpenRouter connected")
|
toast.success("OpenRouter connected")
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -147,19 +226,24 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
|
|
||||||
if (emailValue) update.rxresumeEmail = emailValue
|
|
||||||
if (passwordValue) update.rxresumePassword = passwordValue
|
|
||||||
|
|
||||||
if (Object.keys(update).length === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingEnv(true)
|
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
|
||||||
await api.updateSettings(update)
|
if (!validation.valid) {
|
||||||
await refreshSettings()
|
toast.error(validation.message || "RxResume validation failed")
|
||||||
setRxresumePassword("")
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
|
||||||
|
if (emailValue) update.rxresumeEmail = emailValue
|
||||||
|
if (passwordValue) update.rxresumePassword = passwordValue
|
||||||
|
|
||||||
|
if (Object.keys(update).length > 0) {
|
||||||
|
setIsSavingEnv(true)
|
||||||
|
await api.updateSettings(update)
|
||||||
|
await refreshSettings()
|
||||||
|
setRxresumePassword("")
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("RxResume connected")
|
toast.success("RxResume connected")
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -173,8 +257,13 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
const handleUploadResume = async (): Promise<boolean> => {
|
const handleUploadResume = async (): Promise<boolean> => {
|
||||||
if (!resumeFile) {
|
if (!resumeFile) {
|
||||||
toast.info("Choose your base.json file")
|
const validation = await validateResume()
|
||||||
return false
|
if (!validation.valid) {
|
||||||
|
toast.info(validation.message || "Upload your resume JSON to continue")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -188,7 +277,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.uploadProfile(parsed)
|
await api.uploadProfile(parsed)
|
||||||
await refreshProfileStatus()
|
await validateResume()
|
||||||
setResumeFile(null)
|
setResumeFile(null)
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = ""
|
||||||
@ -209,14 +298,15 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
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 || isCheckingProfile
|
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
|
||||||
const canGoBack = stepIndex > 0
|
const canGoBack = stepIndex > 0
|
||||||
const canGoForward = stepIndex < steps.length - 1
|
|
||||||
const primaryLabel = currentStep === "resume"
|
const primaryLabel = currentStep === "resume"
|
||||||
? (hasBaseResume ? "Finish" : "Upload and finish")
|
? (resumeValidation.valid ? "Finish" : "Upload and validate")
|
||||||
: (currentStep === "openrouter" && !hasOpenrouterKey) || (currentStep === "rxresume" && !hasRxresumeCredentials)
|
: currentStep === "openrouter"
|
||||||
? "Save"
|
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||||
: "Continue"
|
: currentStep === "rxresume"
|
||||||
|
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||||
|
: "Validate"
|
||||||
|
|
||||||
const handlePrimaryAction = async () => {
|
const handlePrimaryAction = async () => {
|
||||||
if (!currentStep) return
|
if (!currentStep) return
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { webhookRouter } from './routes/webhook.js';
|
|||||||
import { profileRouter } from './routes/profile.js';
|
import { profileRouter } from './routes/profile.js';
|
||||||
import { databaseRouter } from './routes/database.js';
|
import { databaseRouter } from './routes/database.js';
|
||||||
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
|
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
|
||||||
|
import { onboardingRouter } from './routes/onboarding.js';
|
||||||
|
|
||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
|
|
||||||
@ -24,3 +25,4 @@ apiRouter.use('/webhook', webhookRouter);
|
|||||||
apiRouter.use('/profile', profileRouter);
|
apiRouter.use('/profile', profileRouter);
|
||||||
apiRouter.use('/database', databaseRouter);
|
apiRouter.use('/database', databaseRouter);
|
||||||
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
|
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
|
||||||
|
apiRouter.use('/onboarding', onboardingRouter);
|
||||||
|
|||||||
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export const onboardingRouter = Router();
|
||||||
|
|
||||||
|
type ValidationResponse = {
|
||||||
|
valid: boolean;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function validateOpenrouter(apiKey?: string | null): Promise<ValidationResponse> {
|
||||||
|
const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || '';
|
||||||
|
if (!key) {
|
||||||
|
return { valid: false, message: 'OpenRouter API key is missing.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/key', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
if (payload && typeof payload === 'object' && 'error' in payload) {
|
||||||
|
const errorObj = payload.error as { message?: string; code?: number | string };
|
||||||
|
const message = errorObj?.message || '';
|
||||||
|
const code = errorObj?.code ? ` (${errorObj.code})` : '';
|
||||||
|
detail = `${message}${code}`.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { valid: false, message: 'Invalid OpenRouter API key. Check the key and try again.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = `OpenRouter returned ${response.status}`;
|
||||||
|
return { valid: false, message: detail || fallback };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: null };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'OpenRouter validation failed.';
|
||||||
|
return { valid: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateResumeJson(): Promise<ValidationResponse> {
|
||||||
|
try {
|
||||||
|
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||||
|
if (!fileInfo.isFile() || fileInfo.size === 0) {
|
||||||
|
return { valid: false, message: 'Resume JSON is missing.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: null };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
|
||||||
|
return { valid: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateRxresume(email?: string | null, password?: string | null): Promise<ValidationResponse> {
|
||||||
|
const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || '';
|
||||||
|
const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || '';
|
||||||
|
|
||||||
|
if (!rxEmail || !rxPassword) {
|
||||||
|
return { valid: false, message: 'RxResume credentials are missing.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await RxResumeClient.verifyCredentials(rxEmail, rxPassword);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
return { valid: true, message: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMessage = result.message?.toLowerCase() ?? '';
|
||||||
|
if (result.status === 401 || normalizedMessage.includes('invalidcredentials')) {
|
||||||
|
return { valid: false, message: 'Invalid RxResume credentials. Check your email and password and try again.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = result.message || `RxResume validation failed (HTTP ${result.status})`;
|
||||||
|
return { valid: false, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardingRouter.post('/validate/openrouter', async (req: Request, res: Response) => {
|
||||||
|
const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined;
|
||||||
|
const result = await validateOpenrouter(apiKey);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response) => {
|
||||||
|
const email = typeof req.body?.email === 'string' ? req.body.email : undefined;
|
||||||
|
const password = typeof req.body?.password === 'string' ? req.body.password : undefined;
|
||||||
|
const result = await validateRxresume(email, password);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
|
||||||
|
const result = await validateResumeJson();
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
});
|
||||||
@ -61,7 +61,10 @@ profileRouter.post('/upload', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const parsed = resumeDataSchema.safeParse(profile);
|
const parsed = resumeDataSchema.safeParse(profile);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const details = parsed.error.issues[0]?.message ?? 'Resume JSON does not match the RxResume schema.';
|
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}`);
|
throw new Error(`Invalid resume JSON: ${details}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,8 +52,6 @@ export async function generatePdf(
|
|||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||||
|
|
||||||
const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure output directory exists
|
// Ensure output directory exists
|
||||||
if (!existsSync(OUTPUT_DIR)) {
|
if (!existsSync(OUTPUT_DIR)) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ let cachedProfile: any = null;
|
|||||||
let cachedProfilePath: string | null = null;
|
let cachedProfilePath: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the base resume profile from base.json.
|
* Get the base resume profile from resume.json.
|
||||||
* Caches the result since it doesn't change often.
|
* Caches the result since it doesn't change often.
|
||||||
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
||||||
* @param forceRefresh Force reload from disk.
|
* @param forceRefresh Force reload from disk.
|
||||||
|
|||||||
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
type AnyObj = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type VerifyResult =
|
||||||
|
| { ok: true }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
// Message is best-effort; server responses vary.
|
||||||
|
message?: string;
|
||||||
|
// Some APIs include error codes/details.
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RxResumeClient {
|
||||||
|
constructor(private readonly baseURL = 'https://v4.rxresu.me') { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a username/password combo WITHOUT persisting a logged-in session.
|
||||||
|
*
|
||||||
|
* Reality check:
|
||||||
|
* - Most sites only expose "verify" by attempting login.
|
||||||
|
* - This method does a stateless request to test credentials.
|
||||||
|
*/
|
||||||
|
static async verifyCredentials(
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
baseURL = 'https://v4.rxresu.me'
|
||||||
|
): Promise<VerifyResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseURL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ identifier, password }),
|
||||||
|
// No credentials mode - we don't want to persist cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) return { ok: true };
|
||||||
|
|
||||||
|
// Best-effort message extraction
|
||||||
|
let data: AnyObj = {};
|
||||||
|
try {
|
||||||
|
const text = await res.text();
|
||||||
|
data = text ? (JSON.parse(text) as AnyObj) : {};
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
(typeof data === 'string' ? data : undefined) ??
|
||||||
|
(typeof data?.message === 'string' ? data.message : undefined) ??
|
||||||
|
(typeof data?.error === 'string' ? data.error : undefined) ??
|
||||||
|
(typeof data?.statusMessage === 'string' ? data.statusMessage : undefined);
|
||||||
|
|
||||||
|
return { ok: false, status: res.status, message, details: data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
message: error instanceof Error ? error.message : 'Network error',
|
||||||
|
details: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// RESERVED FOR FUTURE USE
|
||||||
|
// The following methods support full resume lifecycle management via the
|
||||||
|
// RxResume API. They are not currently used but are kept for future features.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/login
|
||||||
|
* Returns the auth token on success.
|
||||||
|
*/
|
||||||
|
async login(identifier: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ identifier, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Login failed: HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as AnyObj;
|
||||||
|
// The API may return the token in different ways
|
||||||
|
const token =
|
||||||
|
data?.accessToken ??
|
||||||
|
data?.access_token ??
|
||||||
|
data?.token ??
|
||||||
|
(data?.data as AnyObj)?.accessToken ??
|
||||||
|
(data?.data as AnyObj)?.token;
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/resume/import
|
||||||
|
*/
|
||||||
|
async create(resumeData: unknown, token: string): Promise<string> {
|
||||||
|
const res = await fetch(`${this.baseURL}/api/resume/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ data: resumeData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Create failed: HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = (await res.json()) as AnyObj;
|
||||||
|
const id =
|
||||||
|
d?.id ??
|
||||||
|
(d?.data as AnyObj)?.id ??
|
||||||
|
(d?.resume as AnyObj)?.id ??
|
||||||
|
(d?.result as AnyObj)?.id ??
|
||||||
|
(d?.payload as AnyObj)?.id ??
|
||||||
|
((d?.data as AnyObj)?.resume as AnyObj)?.id;
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/resume/print/:id
|
||||||
|
* Returns the print URL from the response.
|
||||||
|
*/
|
||||||
|
async print(resumeId: string, token: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this.baseURL}/api/resume/print/${encodeURIComponent(resumeId)}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Print failed: HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = (await res.json()) as AnyObj;
|
||||||
|
const url =
|
||||||
|
d?.url ??
|
||||||
|
d?.href ??
|
||||||
|
(d?.data as AnyObj)?.url ??
|
||||||
|
(d?.data as AnyObj)?.href ??
|
||||||
|
(d?.result as AnyObj)?.url ??
|
||||||
|
(d?.result as AnyObj)?.href;
|
||||||
|
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/resume/:id
|
||||||
|
*/
|
||||||
|
async delete(resumeId: string, token: string): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this.baseURL}/api/resume/${encodeURIComponent(resumeId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -336,6 +336,11 @@ export interface ProfileStatusResponse {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
model: string;
|
model: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user