onboarding validation for each field
This commit is contained in:
parent
6955a77af8
commit
9c1252c7fd
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
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);
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user