onboarding validation for each field

This commit is contained in:
DaKheera47 2026-01-22 21:29:05 +00:00
parent 6955a77af8
commit 9c1252c7fd
9 changed files with 506 additions and 52 deletions

View File

@ -21,6 +21,7 @@ import type {
VisaSponsor,
ResumeProfile,
ProfileStatusResponse,
ValidationResult,
} from '../../shared/types';
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: {
model?: string | null
modelScorer?: string | null

View File

@ -13,14 +13,32 @@ import * as api from "@client/api"
import { useSettings } from "@client/hooks/useSettings"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
import type { ProfileStatusResponse, ResumeProfile } from "@shared/types"
import type { ResumeProfile, ValidationResult } from "@shared/types"
type ValidationState = ValidationResult & { checked: boolean }
export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
const [profileStatus, setProfileStatus] = useState<ProfileStatusResponse | null>(null)
const [isCheckingProfile, setIsCheckingProfile] = useState(false)
const [isSavingEnv, setIsSavingEnv] = useState(false)
const [isUploadingResume, setIsUploadingResume] = useState(false)
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
const [isValidatingResume, setIsValidatingResume] = useState(false)
const [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 [openrouterApiKey, setOpenrouterApiKey] = useState("")
@ -29,31 +47,62 @@ export const OnboardingGate: React.FC = () => {
const [resumeFile, setResumeFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const refreshProfileStatus = useCallback(async () => {
setIsCheckingProfile(true)
const validateResume = useCallback(async () => {
setIsValidatingResume(true)
try {
const status = await api.getProfileStatus()
setProfileStatus(status)
const result = await api.validateResumeJson()
setResumeValidation({ ...result, checked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to check base resume"
setProfileStatus({ exists: false, error: message })
const message = error instanceof Error ? error.message : "Resume validation failed"
const result = { valid: false, message }
setResumeValidation({ ...result, checked: true })
return result
} finally {
setIsCheckingProfile(false)
setIsValidatingResume(false)
}
}, [])
useEffect(() => {
void refreshProfileStatus()
}, [refreshProfileStatus])
const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true)
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 hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
const hasRxresumeCredentials = hasRxresumeEmail && hasRxresumePassword
const hasBaseResume = Boolean(profileStatus?.exists)
const hasBaseResume = resumeValidation.valid
const shouldOpen = Boolean(settings && profileStatus && !settingsLoading && !isCheckingProfile)
&& !(hasOpenrouterKey && hasRxresumeCredentials && hasBaseResume)
const shouldOpen = Boolean(settings && !settingsLoading)
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
@ -71,22 +120,22 @@ export const OnboardingGate: React.FC = () => {
id: "openrouter",
label: "Connect AI",
subtitle: "OpenRouter key",
complete: hasOpenrouterKey,
complete: openrouterValidation.valid,
},
{
id: "rxresume",
label: "PDF Export",
subtitle: "RxResume login",
complete: hasRxresumeCredentials,
complete: rxresumeValidation.valid,
},
{
id: "resume",
label: "Resume JSON",
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
@ -98,8 +147,30 @@ export const OnboardingGate: React.FC = () => {
}
}, [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 results = await Promise.allSettled([refreshSettings(), refreshProfileStatus()])
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
const failed = results.find((result) => result.status === "rejected")
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null
@ -110,17 +181,25 @@ export const OnboardingGate: React.FC = () => {
const handleSaveOpenrouter = async (): Promise<boolean> => {
const openrouterValue = openrouterApiKey.trim()
if (hasOpenrouterKey && !openrouterValue) return true
if (!openrouterValue) {
if (!openrouterValue && !hasOpenrouterKey) {
toast.info("Add your OpenRouter API key to continue")
return false
}
try {
setIsSavingEnv(true)
await api.updateSettings({ openrouterApiKey: openrouterValue })
await refreshSettings()
setOpenrouterApiKey("")
const validation = await validateOpenrouter(openrouterValue || undefined)
if (!validation.valid) {
toast.error(validation.message || "OpenRouter validation failed")
return false
}
if (openrouterValue) {
setIsSavingEnv(true)
await api.updateSettings({ openrouterApiKey: openrouterValue })
await refreshSettings()
setOpenrouterApiKey("")
}
toast.success("OpenRouter connected")
return true
} catch (error) {
@ -147,19 +226,24 @@ export const OnboardingGate: React.FC = () => {
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 {
setIsSavingEnv(true)
await api.updateSettings(update)
await refreshSettings()
setRxresumePassword("")
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
if (!validation.valid) {
toast.error(validation.message || "RxResume validation failed")
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")
return true
} catch (error) {
@ -173,8 +257,13 @@ export const OnboardingGate: React.FC = () => {
const handleUploadResume = async (): Promise<boolean> => {
if (!resumeFile) {
toast.info("Choose your base.json file")
return false
const validation = await validateResume()
if (!validation.valid) {
toast.info(validation.message || "Upload your resume JSON to continue")
return false
}
return true
}
try {
@ -188,7 +277,7 @@ export const OnboardingGate: React.FC = () => {
}
await api.uploadProfile(parsed)
await refreshProfileStatus()
await validateResume()
setResumeFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
@ -209,14 +298,15 @@ export const OnboardingGate: React.FC = () => {
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
const completedSteps = steps.filter((step) => step.complete).length
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isCheckingProfile
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
const canGoBack = stepIndex > 0
const canGoForward = stepIndex < steps.length - 1
const primaryLabel = currentStep === "resume"
? (hasBaseResume ? "Finish" : "Upload and finish")
: (currentStep === "openrouter" && !hasOpenrouterKey) || (currentStep === "rxresume" && !hasRxresumeCredentials)
? "Save"
: "Continue"
? (resumeValidation.valid ? "Finish" : "Upload and validate")
: currentStep === "openrouter"
? (openrouterValidation.valid ? "Revalidate" : "Validate")
: currentStep === "rxresume"
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
: "Validate"
const handlePrimaryAction = async () => {
if (!currentStep) return

View File

@ -12,6 +12,7 @@ import { webhookRouter } from './routes/webhook.js';
import { profileRouter } from './routes/profile.js';
import { databaseRouter } from './routes/database.js';
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
import { onboardingRouter } from './routes/onboarding.js';
export const apiRouter = Router();
@ -24,3 +25,4 @@ apiRouter.use('/webhook', webhookRouter);
apiRouter.use('/profile', profileRouter);
apiRouter.use('/database', databaseRouter);
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
apiRouter.use('/onboarding', onboardingRouter);

View 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 });
});

View File

@ -61,7 +61,10 @@ profileRouter.post('/upload', async (req: Request, res: Response) => {
const parsed = resumeDataSchema.safeParse(profile);
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}`);
}

View File

@ -52,8 +52,6 @@ export async function generatePdf(
): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId}...`);
const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json');
try {
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {

View File

@ -9,7 +9,7 @@ let cachedProfile: any = 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.
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
* @param forceRefresh Force reload from disk.

View 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}`);
}
}
}

View File

@ -336,6 +336,11 @@ export interface ProfileStatusResponse {
error: string | null;
}
export interface ValidationResult {
valid: boolean;
message: string | null;
}
export interface AppSettings {
model: string;
defaultModel: string;