From 9c1252c7fd9569c4918cf67f7556262f5ad067ca Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 21:29:05 +0000 Subject: [PATCH] onboarding validation for each field --- orchestrator/src/client/api/client.ts | 19 ++ .../src/client/components/OnboardingGate.tsx | 186 +++++++++++---- orchestrator/src/server/api/routes.ts | 2 + .../src/server/api/routes/onboarding.ts | 124 ++++++++++ orchestrator/src/server/api/routes/profile.ts | 5 +- orchestrator/src/server/services/pdf.ts | 2 - orchestrator/src/server/services/profile.ts | 2 +- .../src/server/services/rxresume-client.ts | 213 ++++++++++++++++++ orchestrator/src/shared/types.ts | 5 + 9 files changed, 506 insertions(+), 52 deletions(-) create mode 100644 orchestrator/src/server/api/routes/onboarding.ts create mode 100644 orchestrator/src/server/services/rxresume-client.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 626e3f8..4ddd393 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { + return fetchApi('/onboarding/validate/openrouter', { + method: 'POST', + body: JSON.stringify({ apiKey }), + }); +} + +export async function validateRxresume(email?: string, password?: string): Promise { + return fetchApi('/onboarding/validate/rxresume', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export async function validateResumeJson(): Promise { + return fetchApi('/onboarding/validate/resume'); +} + export async function updateSettings(update: { model?: string | null modelScorer?: string | null diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 3a5a863..f92fb49 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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(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({ + valid: false, + message: null, + checked: false, + }) + const [rxresumeValidation, setRxresumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [resumeValidation, setResumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") @@ -29,31 +47,62 @@ export const OnboardingGate: React.FC = () => { const [resumeFile, setResumeFile] = useState(null) const fileInputRef = useRef(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 => { 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 => { 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 diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index ffb826c..33befd9 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -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); diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts new file mode 100644 index 0000000..723c620 --- /dev/null +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -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 { + 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 { + 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 { + 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 }); +}); diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index 9c7307d..fc50cba 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -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}`); } diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 2037672..65bfefd 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -52,8 +52,6 @@ export async function generatePdf( ): Promise { 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)) { diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts index b30e0c2..dd935dd 100644 --- a/orchestrator/src/server/services/profile.ts +++ b/orchestrator/src/server/services/profile.ts @@ -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. diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume-client.ts new file mode 100644 index 0000000..ca15e14 --- /dev/null +++ b/orchestrator/src/server/services/rxresume-client.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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}`); + } + } +} diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 5d69d1e..440d424 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -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;