Merge pull request #20 from DaKheera47/user-onboarding

User onboarding UI
This commit is contained in:
Shaheer Sarfaraz 2026-01-22 22:05:31 +00:00 committed by GitHub
commit d58dcbb441
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2429 additions and 950 deletions

View File

@ -17,20 +17,17 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b
## Quick Start ## Quick Start
```bash ```bash
# 1. Setup environment # 1. Run with Docker
cp .env.example .env
# 2. Run with Docker
docker compose up -d --build docker compose up -d --build
# 3. Access Dashboard # 2. Open the dashboard
# http://localhost:3005 # http://localhost:3005
``` ```
## Setup The app will guide you through setup on first launch. The onboarding wizard helps you:
Essential variables in `.env`: - Connect your OpenRouter API key (for AI scoring/tailoring)
- `OPENROUTER_API_KEY`: For job scoring and tailoring. - Add your RxResume credentials (for PDF export)
- `RXRESUME_EMAIL`/`PASSWORD`: To automate PDF exports. - Upload your base resume JSON (exported from RxResume)
## Structure ## Structure
- `/orchestrator`: React frontend + Node.js backend & pipeline. - `/orchestrator`: React frontend + Node.js backend & pipeline.
@ -43,14 +40,8 @@ Orchestrator docs here: `documentation/orchestrator.md`
## Read-only mode (Basic Auth) ## Read-only mode (Basic Auth)
Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` in `.env` to make the app read-only for the public. You can make the app read-only for the public by setting a username and password in the **Settings** page.
After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public. After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public.
2. Put your exported RXResume JSON at `resume-generator/base.json`.
3. Start: `docker compose up -d --build`
4. Open:
- Dashboard/UI: `http://localhost:3005`
- API: `http://localhost:3005/api`
- Health: `http://localhost:3005/health`
Persistent data lives in `./data` (bind-mounted into the container). Persistent data lives in `./data` (bind-mounted into the container).

View File

@ -14,10 +14,6 @@ services:
volumes: volumes:
# Persist database and generated PDFs # Persist database and generated PDFs
- ./data:/app/data - ./data:/app/data
# Base resume JSON (read-only)
- ./resume-generator/base.json:/app/resume-generator/base.json:ro
env_file:
- .env
environment: environment:
# Server config # Server config
- NODE_ENV=production - NODE_ENV=production

View File

@ -38,7 +38,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane":
The PDF is generated from: The PDF is generated from:
- The base resume JSON (`resume-generator/base.json`). - The base resume JSON (uploaded via the Onboarding UI or Settings).
- The job description (used for AI tailoring and project selection). - The job description (used for AI tailoring and project selection).
- Your tailored summary/headline/skills and selected projects. - Your tailored summary/headline/skills and selected projects.

View File

@ -1,61 +1,40 @@
# Self-Hosting (Docker Compose) # Self-Hosting (Docker Compose)
This project is designed to be self-hostable with a single Docker Compose command. The easiest way to run JobOps is via Docker Compose. The app is self-configuring and will guide you through the setup on your first visit.
## Prereqs ## Prereqs
- Docker Desktop or Docker Engine + Compose v2 - Docker Desktop or Docker Engine + Compose v2
- An OpenRouter API key (required for AI scoring and summaries)
- RXResume credentials (only if you want PDF exports)
## 1) Clone and set up environment ## 1) Start the stack
```bash No environment variables are strictly required to start. Simply run:
cp .env.example .env
```
Open `.env` and set at least:
- `OPENROUTER_API_KEY`
Optional but commonly used:
- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation)
- `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs)
- `BASIC_AUTH_USER`, `BASIC_AUTH_PASSWORD` (read-only public, auth required for writes)
## 2) Provide a base resume JSON
The container mounts a base resume JSON at `resume-generator/base.json`.
- Create or copy your exported RXResume JSON to:
- `resume-generator/base.json`
If you do not plan to generate PDFs, you can still provide a minimal JSON file to satisfy the mount.
## 3) Start the stack
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
This will build a single container that runs the API, UI, scrapers, and resume generator. This builds a single container that runs the API, UI, scrapers, and resume generator.
## 4) Access the app ## 2) Access the app and Onboard
- Dashboard: http://localhost:3005 Open your browser to:
- API: http://localhost:3005/api - **Dashboard**: http://localhost:3005
- Health: http://localhost:3005/health
On first launch, you will be greeted by an **Onboarding Wizard**. The app will help you validate and save your configuration:
1. **Connect AI**: Add your OpenRouter API key (required for job scoring and summaries).
2. **PDF Export**: Add your RxResume credentials (if you want to generate tailored PDFs).
3. **Resume JSON**: Upload your base resume JSON (exported from RxResume).
The app saves these to its persistent database, so you don't need to manage `.env` files for basic setup. All other settings (like search terms, job sources, and more) can also be configured directly in the UI.
## Persistent data ## Persistent data
`./data` is bind-mounted into the container. It stores: `./data` is bind-mounted into the container. It stores:
- SQLite DB: `data/jobs.db` - SQLite DB: `data/jobs.db` (contains your API keys and configuration)
- Generated PDFs: `data/pdfs/` - Generated PDFs: `data/pdfs/`
- Resume JSON: Stored internally after upload.
## Common issues
- First build is slow: Playwright + Camoufox download Firefox during the image build.
- Scraping can be blocked by target sites (LinkedIn/Indeed/UKVisa). Retry or adjust sources.
- Missing `resume-generator/base.json` will break PDF generation (and the mount).
## Updating ## Updating

View File

@ -33,7 +33,7 @@ orchestrator/
2. **Set up environment:** 2. **Set up environment:**
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with your API keys # The app is self-configuring. You can add keys via the UI Onboarding.
``` ```
3. **Initialize database:** 3. **Initialize database:**

File diff suppressed because it is too large Load Diff

View File

@ -27,11 +27,14 @@
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -48,7 +51,6 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -75,6 +77,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"

View File

@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
import { OnboardingGate } from "./components/OnboardingGate";
export const App: React.FC = () => { export const App: React.FC = () => {
const location = useLocation(); const location = useLocation();
@ -27,6 +28,7 @@ export const App: React.FC = () => {
return ( return (
<> <>
<OnboardingGate />
<SwitchTransition mode="out-in"> <SwitchTransition mode="out-in">
<CSSTransition <CSSTransition
key={pageKey} key={pageKey}

View File

@ -8,7 +8,6 @@ import type {
JobsListResponse, JobsListResponse,
PipelineStatusResponse, PipelineStatusResponse,
JobSource, JobSource,
PipelineRun,
AppSettings, AppSettings,
ResumeProjectsSettings, ResumeProjectsSettings,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
@ -21,6 +20,8 @@ import type {
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
VisaSponsor, VisaSponsor,
ResumeProfile, ResumeProfile,
ProfileStatusResponse,
ValidationResult,
} from '../../shared/types'; } from '../../shared/types';
import { trackEvent } from "@/lib/analytics"; import { trackEvent } from "@/lib/analytics";
@ -179,6 +180,34 @@ export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile'); return fetchApi<ResumeProfile>('/profile');
} }
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status');
}
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/upload', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
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

View File

@ -0,0 +1,501 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Check } from "lucide-react"
import { toast } from "sonner"
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils"
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 { ResumeProfile, ValidationResult } from "@shared/types"
type ValidationState = ValidationResult & { checked: boolean }
export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
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("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [resumeFile, setResumeFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const validateResume = useCallback(async () => {
setIsValidatingResume(true)
try {
const result = await api.validateResumeJson()
setResumeValidation({ ...result, checked: true })
return result
} catch (error) {
const message = error instanceof Error ? error.message : "Resume validation failed"
const result = { valid: false, message }
setResumeValidation({ ...result, checked: true })
return result
} finally {
setIsValidatingResume(false)
}
}, [])
const validateOpenrouter = useCallback(async (apiKey?: string) => {
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 hasBaseResume = resumeValidation.valid
const shouldOpen = Boolean(settings && !settingsLoading)
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
: undefined
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail
: undefined
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings.rxresumePasswordHint)
: undefined
const steps = useMemo(
() => [
{
id: "openrouter",
label: "Connect AI",
subtitle: "OpenRouter key",
complete: openrouterValidation.valid,
},
{
id: "rxresume",
label: "PDF Export",
subtitle: "RxResume login",
complete: rxresumeValidation.valid,
},
{
id: "resume",
label: "Resume JSON",
subtitle: "Upload your file",
complete: resumeValidation.valid,
},
],
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid]
)
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
useEffect(() => {
if (!shouldOpen) return
if (!currentStep && defaultStep) {
setCurrentStep(defaultStep)
}
}, [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(), runAllValidations()])
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 : "Failed to refresh setup"
toast.error(message)
}
}
const handleSaveOpenrouter = async (): Promise<boolean> => {
const openrouterValue = openrouterApiKey.trim()
if (!openrouterValue && !hasOpenrouterKey) {
toast.info("Add your OpenRouter API key to continue")
return false
}
try {
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) {
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key"
toast.error(message)
return false
} finally {
setIsSavingEnv(false)
}
}
const handleSaveRxresume = async (): Promise<boolean> => {
const emailValue = rxresumeEmail.trim()
const passwordValue = rxresumePassword.trim()
const missing: string[] = []
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
if (missing.length > 0) {
toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`,
})
return false
}
try {
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) {
const message = error instanceof Error ? error.message : "Failed to save RxResume credentials"
toast.error(message)
return false
} finally {
setIsSavingEnv(false)
}
}
const handleUploadResume = async (): Promise<boolean> => {
if (!resumeFile) {
const validation = await validateResume()
if (!validation.valid) {
toast.info(validation.message || "Upload your resume JSON to continue")
return false
}
return true
}
try {
setIsUploadingResume(true)
const text = await resumeFile.text()
let parsed: ResumeProfile
try {
parsed = JSON.parse(text) as ResumeProfile
} catch {
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
}
await api.uploadProfile(parsed)
await validateResume()
setResumeFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
toast.success("Resume uploaded")
return true
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to upload resume"
toast.error(message)
return false
} finally {
setIsUploadingResume(false)
}
}
const resumeFileName = resumeFile?.name || ""
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
const 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 || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
const canGoBack = stepIndex > 0
const primaryLabel = currentStep === "resume"
? (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
if (currentStep === "openrouter") {
await handleSaveOpenrouter()
return
}
if (currentStep === "rxresume") {
await handleSaveRxresume()
return
}
if (currentStep === "resume") {
if (hasBaseResume) {
await handleRefresh()
return
}
await handleUploadResume()
}
}
const handleBack = () => {
if (!canGoBack) return
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep)
}
if (!shouldOpen || !currentStep) return null
return (
<AlertDialog open>
<AlertDialogContent
className="max-w-3xl max-h-[90vh] overflow-hidden p-0"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<div className="space-y-6 px-6 py-6 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Lets get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep
const isComplete = step.complete
return (
<FieldLabel
key={step.id}
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none"
>
<TabsTrigger
value={step.id}
className={cn(
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
)}
>
<Field orientation="horizontal" className="items-start">
<FieldContent>
<FieldTitle>{step.label}</FieldTitle>
<FieldDescription>{step.subtitle}</FieldDescription>
</FieldContent>
<span
className={cn(
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
isComplete
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
>
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1}
</span>
</Field>
</TabsTrigger>
</FieldLabel>
)
})}
</TabsList>
<TabsContent value="openrouter" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Connect OpenRouter</p>
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
</div>
<SettingsInput
label="OpenRouter API key"
inputProps={{
name: "openrouterApiKey",
value: openrouterApiKey,
onChange: (event) => setOpenrouterApiKey(event.target.value),
}}
type="password"
placeholder="sk-or-v1..."
current={openrouterCurrent}
helper="Create a key at openrouter.ai"
disabled={isSavingEnv}
/>
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Link your RxResume account</p>
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: rxresumeEmail,
onChange: (event) => setRxresumeEmail(event.target.value),
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: rxresumePassword,
onChange: (event) => setRxresumePassword(event.target.value),
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
</div>
</TabsContent>
<TabsContent value="resume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Upload your resume JSON</p>
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label htmlFor="resumeFile" className="text-sm font-medium">
Resume JSON
</label>
<Input
id="resumeFile"
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
disabled={isUploadingResume}
/>
{resumeFileName && (
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
)}
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
Refresh status
</Button>
<Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy ? "Working..." : primaryLabel}
</Button>
</div>
</div>
<Progress value={progressValue} className="h-2" />
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and
we will take a look.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
target="_blank"
rel="noreferrer"
>
Open an issue
</a>
.
</div>
</div>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className className
)} )}
{...props} {...props}

View File

@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field data-[invalid=true]:text-destructive flex w-full gap-3",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start",
],
responsive: [
"@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -1,15 +1,10 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=Lora:wght@400;500;600;700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=Lora:wght@400;500;600;700&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@tailwind utilities;
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@ -132,68 +127,6 @@
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em; --tracking-normal: 0em;
--animate-in: in 0.5s ease-out forwards;
--animate-out: out 0.3s ease-in forwards;
--animate-fade-in: fade-in 0.5s ease-out forwards;
--animate-fade-out: fade-out 0.3s ease-in forwards;
--animate-slide-in-from-left: slide-in-from-left 0.5s ease-out forwards;
--animate-slide-out-to-left: slide-out-to-left 0.3s ease-in forwards;
--animate-slide-in-from-right: slide-in-from-right 0.5s ease-out forwards;
--animate-slide-out-to-right: slide-out-to-right 0.3s ease-in forwards;
--animate-slide-in-from-top: slide-in-from-top 0.5s ease-out forwards;
--animate-slide-out-to-top: slide-out-to-top 0.3s ease-in forwards;
--animate-slide-in-from-bottom: slide-in-from-bottom 0.5s ease-out forwards;
--animate-slide-out-to-bottom: slide-out-to-bottom 0.3s ease-in forwards;
@keyframes in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slide-in-from-left {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-to-left {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-to-right {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes slide-in-from-top {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
@keyframes slide-out-to-top {
from { transform: translateY(0); }
to { transform: translateY(-100%); }
}
@keyframes slide-in-from-bottom {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slide-out-to-bottom {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
} }
.dark { .dark {
@ -255,6 +188,7 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
@ -276,4 +210,4 @@
.page-exit-active { .page-exit-active {
opacity: 0; opacity: 0;
transition: opacity 75ms ease-in; transition: opacity 75ms ease-in;
} }

View File

@ -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);

View File

@ -0,0 +1,273 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js';
import { RxResumeClient } from '@server/services/rxresume-client.js';
describe.sequential('Onboarding API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
let originalFetch: typeof global.fetch;
beforeEach(async () => {
originalFetch = global.fetch;
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
global.fetch = originalFetch;
});
describe('POST /api/onboarding/validate/openrouter', () => {
it('returns invalid when no API key is provided and none in env', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
it('returns invalid when API key is empty string', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: ' ' }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
it('validates an invalid API key against OpenRouter', async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (url.startsWith('https://openrouter.ai/api/v1/key')) {
return Promise.resolve({
ok: false,
status: 401,
json: async () => ({ error: { message: 'invalid api key' } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'sk-or-invalid-key-12345' }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
// Should be invalid because the key is fake
expect(body.data.valid).toBe(false);
});
});
describe('POST /api/onboarding/validate/rxresume', () => {
it('returns invalid when no credentials are provided and none in env', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
it('returns invalid when only email is provided', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'test@example.com' }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
it('returns invalid when only password is provided', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'testpass' }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
it('validates invalid credentials against RxResume', async () => {
vi.spyOn(RxResumeClient, 'verifyCredentials').mockResolvedValue({
ok: false,
status: 401,
message: 'InvalidCredentials',
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'nonexistent@test.com',
password: 'wrongpassword123',
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
// Should be invalid because credentials are fake
expect(body.data.valid).toBe(false);
});
it('handles whitespace-only credentials', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: ' ', password: ' ' }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain('missing');
});
});
describe('GET /api/onboarding/validate/resume', () => {
it('returns invalid when no resume file exists', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
});
it('returns invalid when resume file is empty', async () => {
// Create an empty resume file
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
});
it('returns invalid when resume file is invalid JSON', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, 'not valid json {{{');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
});
it('returns invalid with field path when resume does not match schema', async () => {
const resumePath = join(tempDir, 'resume.json');
// Valid JSON but missing required fields
await writeFile(resumePath, JSON.stringify({ foo: 'bar' }));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
// Should include field path in error message
expect(body.data.message).toBeTruthy();
});
it('returns valid when resume file is valid and matches schema', async () => {
const resumePath = join(tempDir, 'resume.json');
const validResume = createMinimalValidResume();
await writeFile(resumePath, JSON.stringify(validResume));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
});
});
});
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function createMinimalValidResume() {
return {
basics: {
name: 'Test User',
headline: 'Software Developer',
email: 'test@example.com',
phone: '',
location: '',
url: { label: '', href: '' },
customFields: [],
picture: {
url: '',
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
custom: {},
},
metadata: {
template: 'rhyhorn',
layout: [[['summary'], ['skills']]],
css: { value: '', visible: false },
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
typography: {
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: '',
},
};
}

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

@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http'; import type { Server } from 'http';
import { writeFile, stat } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js'; import { startServer, stopServer } from './test-utils.js';
describe.sequential('Profile API routes', () => { describe.sequential('Profile API routes', () => {
@ -16,7 +18,29 @@ describe.sequential('Profile API routes', () => {
await stopServer({ server, closeDb, tempDir }); await stopServer({ server, closeDb, tempDir });
}); });
it('returns empty projects when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual([]);
});
it('returns null profile when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toBeNull();
});
it('returns base resume projects', async () => { it('returns base resume projects', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile/projects`); const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
@ -24,10 +48,206 @@ describe.sequential('Profile API routes', () => {
}); });
it('returns full base resume profile', async () => { it('returns full base resume profile', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile`); const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.data).toBeDefined(); expect(body.data).toBeDefined();
expect(typeof body.data).toBe('object'); expect(typeof body.data).toBe('object');
}); });
describe('GET /api/profile/status', () => {
it('returns exists: false when resume file does not exist', async () => {
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toBeTruthy();
});
it('returns exists: false when resume file is empty', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.exists).toBe(false);
});
it('returns exists: true when valid resume file exists', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
});
describe('POST /api/profile/upload', () => {
it('rejects request without profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects array as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: [] }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects primitive as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: 'not an object' }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects invalid resume with detailed field path in error', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: { foo: 'bar' } }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid resume JSON');
// Should include field path in error message
expect(body.error).toMatch(/Field "[^"]+"/);
});
it('accepts valid resume and creates file', async () => {
const validResume = createMinimalValidResume();
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: validResume }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
// Verify file was created
const resumePath = join(tempDir, 'resume.json');
const fileInfo = await stat(resumePath);
expect(fileInfo.isFile()).toBe(true);
expect(fileInfo.size).toBeGreaterThan(0);
});
it('overwrites existing resume file', async () => {
const resumePath = join(tempDir, 'resume.json');
const oldResume = createMinimalValidResume();
oldResume.basics.name = 'Old Name';
await writeFile(resumePath, JSON.stringify(oldResume));
const newResume = createMinimalValidResume();
newResume.basics.name = 'New Name';
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: newResume }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
// Verify profile was updated
const profileRes = await fetch(`${baseUrl}/api/profile`);
const profileBody = await profileRes.json();
expect(profileBody.data.basics.name).toBe('New Name');
});
});
}); });
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function createMinimalValidResume() {
return {
basics: {
name: 'Test User',
headline: 'Software Developer',
email: 'test@example.com',
phone: '',
location: '',
url: { label: '', href: '' },
customFields: [],
picture: {
url: '',
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
custom: {},
},
metadata: {
template: 'rhyhorn',
layout: [[['summary'], ['skills']]],
css: { value: '', visible: false },
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
typography: {
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: '',
},
};
}

View File

@ -1,14 +1,30 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { mkdir, stat, writeFile } from 'fs/promises';
import { dirname } from 'path';
import { extractProjectsFromProfile } from '../../services/resumeProjects.js'; import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
import { getProfile } from '../../services/profile.js'; import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js';
import { resumeDataSchema } from '@shared/rxresume-schema.js';
export const profileRouter = Router(); export const profileRouter = Router();
async function profileExists(): Promise<boolean> {
try {
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
return fileInfo.isFile() && fileInfo.size > 0;
} catch {
return false;
}
}
/** /**
* GET /api/profile/projects - Get all projects available in the base resume * GET /api/profile/projects - Get all projects available in the base resume
*/ */
profileRouter.get('/projects', async (req: Request, res: Response) => { profileRouter.get('/projects', async (req: Request, res: Response) => {
try { try {
if (!(await profileExists())) {
res.json({ success: true, data: [] });
return;
}
const profile = await getProfile(); const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile); const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog }); res.json({ success: true, data: catalog });
@ -23,6 +39,10 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
*/ */
profileRouter.get('/', async (req: Request, res: Response) => { profileRouter.get('/', async (req: Request, res: Response) => {
try { try {
if (!(await profileExists())) {
res.json({ success: true, data: null });
return;
}
const profile = await getProfile(); const profile = await getProfile();
res.json({ success: true, data: profile }); res.json({ success: true, data: profile });
} catch (error) { } catch (error) {
@ -30,3 +50,59 @@ profileRouter.get('/', async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}); });
/**
* GET /api/profile/status - Check if base resume exists
*/
profileRouter.get('/status', async (_req: Request, res: Response) => {
try {
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
const exists = fileInfo.isFile() && fileInfo.size > 0;
res.json({ success: true, data: { exists, error: exists ? null : 'Resume file is empty' } });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.json({ success: true, data: { exists: false, error: message } });
}
});
/**
* POST /api/profile/upload - Upload base resume JSON
*/
profileRouter.post('/upload', async (req: Request, res: Response) => {
try {
const profile = (req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>).profile : null) as unknown;
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
throw new Error('Invalid profile payload. Expected a JSON object.');
}
const parsed = resumeDataSchema.safeParse(profile);
if (!parsed.success) {
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}`);
}
const existing = await stat(DEFAULT_PROFILE_PATH).catch(() => null);
if (existing && existing.isDirectory()) {
throw new Error('Resume path is a directory. Remove it and upload again.');
}
await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true });
await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(parsed.data, null, 2), 'utf-8');
clearProfileCache();
res.json({ success: true, data: { exists: true, error: null } });
} catch (error) {
let message = error instanceof Error ? error.message : 'Unknown error';
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
if (code === 'EROFS') {
message = 'Resume path is read-only. Remove the bind mount and restart the container.';
}
}
res.status(400).json({ success: false, error: message });
}
});

View File

@ -74,7 +74,7 @@ export function createApp() {
const authGuard = createBasicAuthGuard(); const authGuard = createBasicAuthGuard();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '5mb' }));
// Logging middleware // Logging middleware
app.use((req, res, next) => { app.use((req, res, next) => {

View File

@ -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)) {

View File

@ -1,15 +1,15 @@
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { join, dirname } from 'path'; import { join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); import { getDataDir } from '../config/dataDir.js';
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json');
let cachedProfile: any = null; 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.

View File

@ -0,0 +1,507 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RxResumeClient } from './rxresume-client.js';
describe('RxResumeClient', () => {
describe('verifyCredentials (static)', () => {
it('returns ok: true for successful login', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/auth/login',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ identifier: 'test@example.com', password: 'password123' }),
})
);
vi.unstubAllGlobals();
});
it('returns ok: false with status 401 for invalid credentials', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => JSON.stringify({ message: 'InvalidCredentials' }),
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'wrong@example.com',
'badpassword',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(401);
expect(result.message).toBe('InvalidCredentials');
}
vi.unstubAllGlobals();
});
it('returns ok: false with error message for other HTTP errors', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => JSON.stringify({ error: 'Internal Server Error' }),
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(500);
expect(result.message).toBe('Internal Server Error');
}
vi.unstubAllGlobals();
});
it('returns ok: false with statusMessage from response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: async () => JSON.stringify({ statusMessage: 'Account suspended' }),
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(403);
expect(result.message).toBe('Account suspended');
}
vi.unstubAllGlobals();
});
it('handles network errors gracefully', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network timeout'));
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(0);
expect(result.message).toBe('Network timeout');
}
vi.unstubAllGlobals();
});
it('handles non-JSON error response body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 502,
text: async () => 'Bad Gateway',
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(502);
// Should handle gracefully even if body is not JSON
expect(result).toBeDefined();
}
vi.unstubAllGlobals();
});
it('handles empty response body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
text: async () => '',
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(404);
}
vi.unstubAllGlobals();
});
it('handles string response directly', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => '"Direct string error"',
});
vi.stubGlobal('fetch', mockFetch);
const result = await RxResumeClient.verifyCredentials(
'test@example.com',
'password123',
'https://mock.rxresume.test'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
expect(result.message).toBe('Direct string error');
}
vi.unstubAllGlobals();
});
it('uses default baseURL when not provided', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal('fetch', mockFetch);
await RxResumeClient.verifyCredentials('test@example.com', 'password123');
expect(mockFetch).toHaveBeenCalledWith(
'https://v4.rxresu.me/api/auth/login',
expect.any(Object)
);
vi.unstubAllGlobals();
});
});
describe('instance methods', () => {
let client: RxResumeClient;
beforeEach(() => {
client = new RxResumeClient('https://mock.rxresume.test');
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('login', () => {
it('returns access token on successful login', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ accessToken: 'mock-token-123' }),
});
vi.stubGlobal('fetch', mockFetch);
const token = await client.login('test@example.com', 'password123');
expect(token).toBe('mock-token-123');
});
it('handles token in data.accessToken format', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: { accessToken: 'nested-token' } }),
});
vi.stubGlobal('fetch', mockFetch);
const token = await client.login('test@example.com', 'password123');
expect(token).toBe('nested-token');
});
it('handles token field instead of accessToken', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ token: 'alt-token-field' }),
});
vi.stubGlobal('fetch', mockFetch);
const token = await client.login('test@example.com', 'password123');
expect(token).toBe('alt-token-field');
});
it('throws error on login failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => 'Unauthorized',
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.login('wrong@example.com', 'badpass')).rejects.toThrow(
'Login failed: HTTP 401'
);
});
it('throws error when token is not found in response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ user: { id: '123' } }),
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.login('test@example.com', 'password123')).rejects.toThrow(
'could not locate access token'
);
});
});
describe('create', () => {
it('returns resume id on successful creation', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'resume-id-123' }),
});
vi.stubGlobal('fetch', mockFetch);
const id = await client.create({ basics: { name: 'Test' } }, 'mock-token');
expect(id).toBe('resume-id-123');
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/resume/import',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer mock-token',
}),
})
);
});
it('handles id in nested data.resume.id format', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: { resume: { id: 'nested-resume-id' } } }),
});
vi.stubGlobal('fetch', mockFetch);
const id = await client.create({}, 'mock-token');
expect(id).toBe('nested-resume-id');
});
it('throws error on creation failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => 'Invalid resume data',
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.create({}, 'mock-token')).rejects.toThrow('Create failed: HTTP 400');
});
it('throws error when id is not found in response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true }),
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.create({}, 'mock-token')).rejects.toThrow(
'could not locate resume id'
);
});
});
describe('print', () => {
it('returns print URL on success', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ url: 'https://pdf.rxresume.test/print/123' }),
});
vi.stubGlobal('fetch', mockFetch);
const url = await client.print('resume-123', 'mock-token');
expect(url).toBe('https://pdf.rxresume.test/print/123');
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/resume/print/resume-123',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer mock-token',
}),
})
);
});
it('handles href field instead of url', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ href: 'https://alt-url.test' }),
});
vi.stubGlobal('fetch', mockFetch);
const url = await client.print('resume-123', 'mock-token');
expect(url).toBe('https://alt-url.test');
});
it('throws error on print failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Resume not found',
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.print('nonexistent', 'mock-token')).rejects.toThrow(
'Print failed: HTTP 404'
);
});
it('throws error when URL is not found in response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' }),
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.print('resume-123', 'mock-token')).rejects.toThrow(
'could not locate URL'
);
});
it('encodes resume ID in URL', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ url: 'https://test.com' }),
});
vi.stubGlobal('fetch', mockFetch);
await client.print('resume with spaces', 'mock-token');
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/resume/print/resume%20with%20spaces',
expect.any(Object)
);
});
});
describe('delete', () => {
it('completes successfully on 200 response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/resume/resume-123',
expect.objectContaining({
method: 'DELETE',
headers: expect.objectContaining({
Authorization: 'Bearer mock-token',
}),
})
);
});
it('completes successfully on 204 No Content', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false, // 204 is technically not "ok" in some implementations
status: 204,
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
});
it('throws error on delete failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
vi.stubGlobal('fetch', mockFetch);
await expect(client.delete('resume-123', 'mock-token')).rejects.toThrow(
'Delete failed: HTTP 403'
);
});
it('encodes resume ID in URL', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
vi.stubGlobal('fetch', mockFetch);
await client.delete('resume/with/slashes', 'mock-token');
expect(mockFetch).toHaveBeenCalledWith(
'https://mock.rxresume.test/api/resume/resume%2Fwith%2Fslashes',
expect.any(Object)
);
});
});
});
describe('default baseURL', () => {
it('uses https://v4.rxresu.me by default', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ accessToken: 'token' }),
});
vi.stubGlobal('fetch', mockFetch);
const client = new RxResumeClient();
await client.login('test@example.com', 'password');
expect(mockFetch).toHaveBeenCalledWith(
'https://v4.rxresu.me/api/auth/login',
expect.any(Object)
);
vi.unstubAllGlobals();
});
});
});

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

@ -11,7 +11,10 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
// Parallelize slow operations // Parallelize slow operations
const [overrides, profile] = await Promise.all([ const [overrides, profile] = await Promise.all([
settingsRepo.getAllSettings(), settingsRepo.getAllSettings(),
getProfile(), getProfile().catch((error) => {
console.warn('Failed to load base resume profile for settings:', error);
return {};
}),
]); ]);
const envSettings = await getEnvSettingsData(overrides); const envSettings = await getEnvSettingsData(overrides);

View File

@ -11,6 +11,7 @@ export type FilterKeys<T, Condition> = {
export const idSchema = z export const idSchema = z
.string() .string()
.cuid2() .cuid2()
.length(24)
.describe("Unique identifier for the item (CUID2 format)"); .describe("Unique identifier for the item (CUID2 format)");
export const itemSchema = z.object({ export const itemSchema = z.object({

View File

@ -331,6 +331,16 @@ export interface ResumeProfile {
[key: string]: any; [key: string]: any;
} }
export interface ProfileStatusResponse {
exists: boolean;
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;

View File

@ -3,24 +3,5 @@ import type { Config } from "tailwindcss";
export default { export default {
darkMode: "class", darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [], plugins: [],
} satisfies Config; } satisfies Config;

View File

@ -2,9 +2,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',

View File

@ -1,661 +0,0 @@
{
"basics": {
"name": "Shaheer Sarfaraz",
"headline": "Frontend Software Engineer (React/TypeScript) \u00b7 Autodesk Intern \u00b7 Open Source & Product Work",
"email": "shaheer30sarfaraz@gmail.com",
"phone": "+44 7359 501592",
"location": "Blackpool, United Kingdom",
"url": {
"label": "https://dakheera47.com/",
"href": "https://dakheera47.com/"
},
"customFields": [],
"picture": {
"url": "",
"size": 120,
"aspectRatio": 1,
"borderRadius": 0,
"effects": {
"hidden": false,
"border": false,
"grayscale": false
}
}
},
"sections": {
"summary": {
"name": "Summary",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "summary",
"content": "<blockquote><p>I\u2019m a BSc (Hons) Computer Science student at the University of Lancashire, graduating in June 2026 with a First-class average, and I\u2019ve spent a year as a Software Engineering Intern at Autodesk working in a large React/TypeScript production codebase. I\u2019m comfortable using Python for scripting, data cleaning, and small backend services, and I have academic experience with SQL from my databases module, which I\u2019ve applied in analytics-focused side projects. I\u2019m particularly interested in AI-driven systems and would be excited to help develop and improve AI agents for marketing and user acquisition while working closely with data scientists, engineers, and marketing/product teams</p></blockquote>"
},
"awards": {
"name": "Awards",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "awards",
"items": []
},
"certifications": {
"name": "Certifications",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "certifications",
"items": []
},
"education": {
"name": "Education",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "education",
"items": [
{
"id": "yo3p200zo45c6cdqc6a2vtt3",
"visible": true,
"institution": "University of Lancashire",
"studyType": "BSc (Hons) Computer Science",
"area": "Preston, United Kingdom",
"score": "1st Class",
"date": "September 2022 to June 2026",
"summary": "<p style=\"text-align: left;\">Relevant Modules: Web Applications, Algorithms &amp; Data Structures, Game Development, Databases, Software Engineering (Agile group project)</p>",
"url": {
"label": "",
"href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc"
}
},
{
"id": "ei2fvjokusg3cfmdyolmgcoz",
"visible": false,
"institution": " ",
"studyType": "",
"area": "A Levels",
"score": "",
"date": "",
"summary": "<ul><li><p>Maths: A</p></li><li><p>Computer Science: B</p></li><li><p>Physics: C</p></li><li><p>Chemistry: E</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "pm4r5hngvv1w4mc79o22irfx",
"visible": false,
"institution": " ",
"studyType": "",
"area": "GCSEs",
"score": "",
"date": "",
"summary": "<ol><li><p>English: A*</p></li><li><p>Computer Science: A*</p></li><li><p>Urdu: A</p></li><li><p>Islamiat: A</p></li><li><p>Pakistan Studies: A</p></li><li><p>Biology: A</p></li><li><p>Chemistry: A</p></li><li><p>Physics: A</p></li><li><p>Maths: A</p></li></ol>",
"url": {
"label": "",
"href": ""
}
}
]
},
"experience": {
"name": "Experience",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "experience",
"items": [
{
"id": "ng9ui2azk7w4y8oyu8kazqeb",
"visible": true,
"company": "Autodesk",
"position": "Software Engineering Intern",
"location": "Hybrid (Sheffield Based)",
"date": "July 2024 - June 2025",
"summary": "<ul><li><p><strong>Implemented front-end features and fixes</strong> in the Autodesk Construction Cloud Model Coordination app, working in a ~10-year-old React/JavaScript/TypeScript codebase (7k+ commits) using Webpack module federation and Autodesk\u2019s Exoskeleton dev environment</p></li><li><p>Improved reliability of the <strong>Cypress end-to-end test suite</strong> by diagnosing flaky tests, adding new E2E coverage, and participating in focused \u201ctest fest\u201d events ahead of major feature releases</p></li><li><p>Collaborated with cross-functional teams (like the Design System, platform teams) by <strong>raising well-scoped bugs</strong>, augmenting existing tickets with reproduction steps and context, and aligning on shared component and API changes</p></li><li><p>Helped strengthen team processes by <strong>running weekly stand-ups</strong> and retrospectives, organising a ticket-scoping meeting, and <strong>participating in technical reviews &amp; ADR discussions</strong> (e.g. standardising error handling and planning clash data streaming)</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "lhw25d7gf32wgdfpsktf6e0x",
"visible": true,
"company": "Mirage",
"position": "Co-Founder & Lead Developer",
"location": "",
"date": "December 2019 to Present",
"summary": "<ul><li><p>Delivered <strong>10+ production websites and webapps</strong> for small and medium size clients (e.g. Indus Marine Services, Mumtaz Urdu), from initial scoping to deployment and handover</p></li><li><p>Built with <strong>modern web stacks</strong> (Next.js, Node/Express, Tailwind, Strapi, WordPress/Elementor where appropriate), setting up CI/CD and hosting</p></li><li><p><strong>Led a small team of four developers</strong>, handling code reviews, task breakdown, and client communication</p></li></ul>",
"url": {
"label": "",
"href": "https://promirage.com/"
}
},
{
"id": "k6zxqunkb225hbjso3c3vykk",
"visible": true,
"company": "University of Lancashire",
"position": "Computing Student Mentor",
"location": "Preston, UK",
"date": "July 2023 - July 2024",
"summary": "<ul><li><p><strong>Academic Support and Leadership:</strong> Provided academic guidance to over 10 first-year students once a week, significantly enhancing their understanding and skills in key subjects like programming and web development.</p></li><li><p style=\"text-align: start\"><strong>Collaborative Learning Environment:</strong> Actively fostered a collaborative and supportive learning environment for a group of 10 students. This role also honed my leadership and communication skills, facilitating better academic outcomes for mentees.</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "a1bg5d8gp8sulf91xzdcsiaq",
"visible": true,
"company": "Research and Knowledge Exchange Institute",
"position": "Undergraduate Research Intern (HCI & EdTech)",
"location": "",
"date": "Summer 2024",
"summary": "<ul><li><p>Built a <strong>mouse \u201ctorch-reveal\u201d web app</strong> (<strong>Astro</strong>) to approximate eye-tracking; ran on-campus studies with Revoe Learning Academy pupils\u2014<strong>1</strong> eye-tracked, <strong>9</strong> using my app.</p></li><li><p>Logged cursor paths, dwell time, and reveal order; delivered setup notes for staff to run sessions independently.</p></li><li><p>Developed a <strong>Questionnaire Randomiser</strong> (Next.js): selectable response metrics (<strong>smileys / numbers / stars</strong>), configurable randomisation strategies, and <strong>ZIP export of per-student PDFs</strong> ready for print.</p></li><li><p>Extras: lightweight analytics for comparison with the eye-tracking baseline; optional CSV/JSON data export.</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "tx32suzrg2bs5eumcbjei4ns",
"visible": false,
"company": "University of Lancashire",
"position": "Student Ambassador",
"location": "Preston, UK",
"date": "July 2023 - Present",
"summary": "<ul><li><p><strong>Diverse Role Engagement:</strong> Actively engaged in various tasks, from guiding tours to assisting on open days, demonstrating adaptability and organizational skills.</p></li><li><p><strong>Campus Culture Promotion:</strong> Contributed to enhancing the university\u2019s inclusive campus atmosphere, showcasing the university's vibrant community to prospective students.</p></li></ul>",
"url": {
"label": "",
"href": ""
}
}
]
},
"volunteer": {
"name": "Volunteering",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "volunteer",
"items": []
},
"interests": {
"name": "Interests",
"columns": 1,
"separateLinks": true,
"visible": false,
"id": "interests",
"items": []
},
"languages": {
"name": "Languages",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "languages",
"items": []
},
"profiles": {
"name": "Profiles",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "profiles",
"items": [
{
"id": "ukl0uecvzkgm27mlye0wazlb",
"visible": true,
"network": "GitHub",
"username": "DaKheera47",
"icon": "github",
"url": {
"label": "",
"href": "https://github.com/DaKheera47"
}
},
{
"id": "cnbk5f0aeqvhx69ebk7hktwd",
"visible": true,
"network": "LinkedIn",
"username": "ssarfaraz30",
"icon": "linkedin",
"url": {
"label": "",
"href": "https://www.linkedin.com/in/ssarfaraz30/"
}
},
{
"id": "linnyxv78zdep1xwirpa2ia1",
"visible": true,
"network": "Hashnode",
"username": "DaKheera47",
"icon": "hashnode",
"url": {
"label": "",
"href": "https://dakheera47.hashnode.dev/"
}
}
]
},
"projects": {
"name": "Projects",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "projects",
"items": [
{
"id": "yw843emozcth8s1ubi1ubvlf",
"visible": false,
"name": "Atoro",
"description": "Lead Developer",
"date": "January 2023",
"summary": "<ol><li><p><strong>Next.js Implementation for Enhanced SEO:</strong> Utilized Next.js to optimize the website for search engines, significantly improving its online visibility and user engagement.</p></li><li><p><strong>Strapi Backend Integration:</strong> Streamlined content management by implementing a Strapi backend, enhancing the efficiency and scalability of the website's content updates.</p></li><li><p><strong>Responsive Design with Tailwind CSS:</strong> Employed Tailwind CSS for a utility-first approach, ensuring the website's responsiveness and seamless user experience across various devices.</p></li><li><p><strong>Continuous Deployment Pipeline Establishment:</strong> Developed a continuous deployment pipeline, ensuring real-time updates and maintaining high performance and reliability of the website.</p></li><li><p><strong>Optimized Web Performance:</strong> Focused on optimizing web performance by efficiently loading images and managing JavaScript bundles, leading to a faster and more efficient user experience.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://atoro.promirage.com"
}
},
{
"id": "ncxgdjjky54gh59iz2t1xi1v",
"visible": false,
"name": "Stellar Consultancy",
"description": "Lead Developer",
"date": "April 2023",
"summary": "<ol><li><p><strong>WordPress and Elementor Integration:</strong> Expertly utilized WordPress with Elementor to build a robust content management system, enhancing the website's scalability and user interaction capabilities.</p></li><li><p><strong>Client Engagement and Trust Building:</strong> Implemented features to showcase client testimonials, effectively building trust and displaying the success of previous project engagements.</p></li><li><p><strong>Intuitive Design and User Engagement:</strong> Focused on intuitive page design and structuring, streamlining site maintenance and content updates, thereby enhancing user engagement.</p></li><li><p><strong>Effective Call-to-Actions:</strong> Crafted clear call-to-actions and provided essential contact information, significantly improving user interaction and conversion rates.</p></li><li><p><strong>Portfolio Display for Business Showcase:</strong> Presented past work and services offered through a comprehensive portfolio display, allowing visitors to assess the quality and impact of Stellar Consultancy's services.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://stellarconsultancy.ca"
}
},
{
"id": "tcecguinuctb8mu2xqrn97m8",
"visible": true,
"name": "Mumtaz Urdu",
"description": "Developer",
"date": "July 2022",
"summary": "<ol><li><p><strong>Server-Rendered Web Application Development</strong>: Created the Mumtaz Urdu platform with Next.js to optimize server-side rendering for enhanced SEO and performance.</p></li><li><p><strong>UI Development with Tailwind CSS</strong>: Implemented utility-first Tailwind CSS, ensuring rapid, responsive design for a seamless user interface.</p></li><li><p><strong>Scalable Storage Solution</strong>: Integrated scalable Amazon S3 storage, supporting the application's growth and robust data management.</p></li><li><p><strong>Progressive Web App Implementation</strong>: Developed PWA features for Mumtaz Urdu, offering users native-like mobile access and increased engagement.</p></li><li><p><strong>High Traffic Data Management</strong>: Engineered Mumtaz Urdu's backend with Next.js and MongoDB, enabling the handling and efficient processing of vast amounts of user data for thousands of monthly users.</p></li><li><p><strong>Test-Driven Development</strong>: Embraced TDD practices to ensure reliable and high-quality code, facilitating regular testing throughout the development process for continuous improvement.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://www.mumtazurdu.com/"
}
},
{
"id": "to47h749kaj6t02j3f9kprxq",
"visible": false,
"name": "PyScreeze",
"description": "Open Source Contribution",
"date": "January 2022",
"summary": "<ol><li><p><strong>Innovative Feature Implementation:</strong> Implemented the <code>locateCenterOnScreenNear</code> function for <code>PyScreeze</code>, enhancing the library's functionality by enabling precise image location near a specified point on the screen.</p></li><li><p><strong>Open Source Contribution:</strong> Marked my debut in open-source contributions with this significant addition to <code>PyScreeze</code>, showcasing my initiative and ability to contribute effectively to community-driven projects.</p></li><li><p><strong>Collaborative Development and Recognition:</strong> Collaborated with the project's maintainer, <code>asweigart</code>, to refine and integrate the function into the main codebase, receiving recognition for this valuable contribution to the project.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://github.com/asweigart/pyscreeze/pull/79"
}
},
{
"id": "gt7yq82ulor5hmmutdhuvfo1",
"visible": false,
"name": "Threegency",
"description": "Lead Developer",
"date": "February 2023",
"summary": "<ul><li><p><strong>Framework</strong>: Utilized Next.js to build a server-rendered React website, enhancing SEO and ensuring optimal performance.</p></li><li><p><strong>Styling</strong>: Employed Tailwind CSS for utility-first styling, facilitating rapid UI development.</p></li><li><p><strong>Content Management</strong>: Leveraged Strapi as a CMS, enabling streamlined content updates and administration.</p></li><li><p><strong>Data Handling</strong>: Utilized GraphQL for data handling, ensuring efficient and flexible data retrieval.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": "https://www.threegency.com"
}
},
{
"id": "c8fcu3nz541a4d5zcurx6b8c",
"visible": false,
"name": "AutoClass",
"description": "GUI Automation",
"date": "November 2021",
"summary": "<ul><li><p><strong>Framework</strong>: Written in Python, leveraging the versatility and ease-of-use of the language.</p></li><li><p><strong>Automation Library</strong>: Utilized PyAutoGUI for automating user interactions, enhancing the utility of the application.</p></li><li><p><strong>Iterative Improvement</strong>: Progressively refined over a year, demonstrating a commitment to robustness and reliability.</p></li><li><p><strong>Project Purpose</strong>: Developed to automate the process of joining Zoom classes, alleviating the repetitive morning routine.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": "https://github.com/DaKheera47/autoclass"
}
},
{
"id": "rv23bgibq6bye6rujmcx1ygc",
"visible": false,
"name": "Meet Link Generator",
"description": "GUI Automation",
"date": "January 2022",
"summary": "<ul><li><p><strong>Functionality</strong>: Generates Google Meet links with specific words in the URL by brute-forcing the creation of thousands of links until the desired pattern is achieved. Doing so enables creation of Google Meet links with specific codes or phrases.</p></li><li><p><strong>Optimized Automation</strong>: The final product uses Python with PyAutoGUI for efficient and rapid creation of new Google Meet links.</p></li><li><p><strong>Speed and Efficiency</strong>: Drastically improved performance, finally achieving the link generation time to under 1 second per link, limited only by internet speed.</p></li><li><p><strong>Interface Interaction</strong>: Utilizes the Google Meet homepage's features for quicker link generation, avoiding full page refreshes for speed.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": "https://github.com/DaKheera47/meet-link-generator"
}
},
{
"id": "tu98rghbi5c43ogget5mh7ih",
"visible": false,
"name": "UCLan Server-side Web Application Project",
"description": "",
"date": "UCLan Year 1",
"summary": "<ul><li><p><strong>Backend Development with PHP and MySQL:</strong> Developed the backend for a Student\u2019s Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.</p></li><li><p><strong>User Authentication and Session Management:</strong> Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.</p></li><li><p><strong>Dynamic Content Display from Database:</strong> Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.</p></li><li><p><strong>Advanced Search and Personalization Features:</strong> Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "ov4lkbc1vl169ynfnj91m1lm",
"visible": false,
"name": "Square About",
"description": "",
"date": "UCLan Year 1",
"summary": "<ul><li><p><strong>Advanced 3D Game Development:</strong> Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.</p></li><li><p><strong>Dynamic Gameplay Elements:</strong> Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.</p></li><li><p><strong>Interactive Game Controls:</strong> Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.</p></li><li><p><strong>Strategic Game Mechanics:</strong> Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "s3r37gdr0oa84a6dp6r5nl58",
"visible": false,
"name": "Car Smash",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>3D Car Smash Game Development:</strong> Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.</p></li><li><p><strong>Collision Detection Mechanics:</strong> Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.</p></li><li><p><strong>Dynamic Game States and Camera Views:</strong> Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.</p></li><li><p><strong>Enhanced Player Interaction:</strong> Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "gylzkvl103m9s7ywag4xpdy4",
"visible": false,
"name": "Tweet Filter",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>Tweet Filtration System:</strong> Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.</p></li><li><p><strong>Advanced Text Manipulation:</strong> Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.</p></li><li><p><strong>Output Generation:</strong> Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.</p></li><li><p><strong>Algorithm Optimization:</strong> Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "enav754zxhuc9uycbb83s94q",
"visible": false,
"name": "Burger Ordering App",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>Interactive Console Application:</strong> Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.</p></li><li><p><strong>Complex Logic Implementation:</strong> Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.</p></li><li><p><strong>Data Handling and User Input:</strong> Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.</p></li><li><p><strong>Readable and Maintainable Code:</strong> Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "hl6jgeswr01tlul3iwoat05d",
"visible": false,
"name": "LinkLander",
"description": "Android Studio, Kotlin",
"date": "December 2023 - Ongoing",
"summary": "<ul><li><p><strong>Innovative Android Utility:</strong> Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.</p></li><li><p><strong>User-Centric Design:</strong> Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.</p></li><li><p><strong>Simplicity and Efficiency:</strong> Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.</p></li><li><p><strong>Technical Proficiency in Kotlin:</strong> Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "v4s0ljbiiio198y8l1wl0ym6",
"visible": false,
"name": "AR App Development with AGILE",
"description": "Unity, C#",
"date": "October 2023 - Ongoing",
"summary": "<ul><li><p><strong>Agile Development in Action</strong>: Participated in an Agile team project, developing an AR application for supporting disabled students with a team of five, demonstrating an application of Agile methodologies in a real-world scenario.</p></li><li><p><strong>Mobile AR Application Prototype</strong>: Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.</p></li><li><p><strong>Collaborative Software Engineering</strong>: Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.</p></li><li><p><strong>Presentation and Critical Analysis</strong>: Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "fwxrq682hqrj1y76rmziqrbk",
"visible": true,
"name": "Indus Marine Services",
"description": "System Design & Development",
"date": "May 2022 - Ongoing",
"summary": "<ol><li><p><strong>Induction System for Marine Services</strong>: Designed &amp; developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.</p></li><li><p><strong>Admin-Centric Functionality</strong>: Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements</p></li><li><p><strong>Client Engagement Interface</strong>: Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.</p></li><li><p><strong>Robust Tech Stack Integration</strong>: Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "http://www.ims-auh.com"
}
},
{
"id": "jdfyaez8vq1b7xfr9rmxmz06",
"visible": false,
"name": "VECTOR AI",
"description": "Website Development",
"date": "February 2024 - February 2024",
"summary": "<ol><li><p><strong>Innovative AI Development</strong>: As the driving force behind VECTOR's website development, I spearheaded the technical design using Astro, with a cutting-edge stack including React and Tailwind CSS.</p></li><li><p><strong>Data-Driven Content Strategy</strong>: Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.</p></li><li><p><strong>Astro for Enhanced Performance</strong>: Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience</p></li><li><p><strong>React for Responsive Interaction</strong>: Utilized React\u2019s robust ecosystem to develop interactive elements, ensuring that each module of VECTOR\u2019s platform is engaging and seamless for users across various touchpoints.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://vector-ai.co/"
}
},
{
"id": "qdhmfkqpfql19ohfas1g91ek",
"visible": false,
"name": "UCLan's First Hackathon",
"description": "Hackathon, Team Work",
"date": "February 2024",
"summary": "<ol><li><p><strong>Second Place in UCLan Hackathon</strong>: Earned second place in UCLan's first hackathon by developing an app to simplify university life. Focused on enhancing the attendance monitoring process for student mentors.</p></li><li><p><strong>TRPC for End-to-End Type Safety</strong>: Utilized TRPC to ensure end-to-end type safety, enhancing the app's reliability and streamlining the development process.</p></li><li><p><strong>Supabase Backend Integration</strong>: Implemented Supabase as a backend solution, providing a robust and scalable database for managing attendance data efficiently.</p></li><li><p><strong>Amazon SES and OAuth Integration</strong>: Integrated Amazon SES for email notifications and OAuth for secure Google login, improving user experience and communication.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "rw3x7tapntrt877rbl4pnxz7",
"visible": true,
"name": "NASA Space Apps Challenge",
"description": "A 48-hour, global hackathon powered by NASA open data",
"date": "Oct 4\u20135, 2025",
"summary": "<ol><li><p><strong>Full-Stack Integration:</strong> Wired up backend services to a responsive frontend, enabling real-time exploration of <strong>Kepler/K2/TESS</strong> catalogs and smooth model-scoring UX.</p></li><li><p><strong>Data Harmonization Pipeline:</strong> Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by <strong>60%+</strong> during the hack.</p></li><li><p><strong>Analytics UI &amp; Upload Flow:</strong> Built an upload \u2192 validate \u2192 score workflow and a clear results dashboard so researchers can triage candidates in minutes, not hours.</p></li><li><p><strong>Delivery Under Pressure:</strong> Coordinated a <strong>5-person</strong> multidisciplinary team to ship a working web app in <strong>48 hours</strong>, with demo-ready reliability for judging.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://exploranium.vercel.app/dashboard"
}
},
{
"id": "i2t6epmx5v7s0d8rqtxsigp3",
"visible": true,
"name": "Strong Statistics",
"description": "Self-hosted strength analytics app using FastAPI and Next.js to visualize Strong app data with full local privacy and active open-source adoption.",
"date": "September 2025 - Present",
"summary": "<ol><li><p><strong>Self-Hosted Strength Analytics Platform:</strong> Developed <em>strong-statistics</em>, an open-source web app that visualizes detailed workout analytics from the <strong>Strong </strong>and<strong> Hevy</strong> fitness app, giving users local control of their training data.</p></li><li><p><strong>Full-Stack Architecture:</strong> Built a modular stack with <strong>FastAPI</strong>, <strong>Next.js</strong>, <strong>Tailwind CSS</strong>, and <strong>SQLite</strong>, deployed via <strong>Docker Compose</strong> for seamless self-hosting and persistent local data storage.</p></li><li><p><strong>Active Open-Source Ecosystem:</strong> Published on GitHub with <strong>community engagement from global users</strong> \u2014 external contributors opened feature requests and bug reports, validating real-world adoption and reliability.</p></li><li><p><strong>Continuous Personal Use &amp; Maintenance:</strong> Regularly updated and used in live deployment at <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"http://lifting.dakheera47.com\">lifting.dakheera47.com</a>, tracking <strong>hundreds of sets</strong> over time with persistent analytics and performance trends.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://lifting.dakheera47.com/"
}
}
]
},
"publications": {
"name": "Publications",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "publications",
"items": []
},
"references": {
"name": "References",
"columns": 1,
"separateLinks": true,
"visible": false,
"id": "references",
"items": [
{
"id": "f2sv5z0cce6ztjl87yuk8fak",
"visible": true,
"name": "Available upon request",
"description": "",
"summary": "",
"url": {
"label": "",
"href": ""
}
}
]
},
"skills": {
"name": "Skills",
"columns": 2,
"separateLinks": true,
"visible": true,
"id": "skills",
"items": [
{
"id": "jfgzfcwcg65k9gemuxlfe9m3",
"visible": true,
"name": "Frontend Development",
"description": "",
"level": 0,
"keywords": [
"React",
"Next.js",
"Tailwind CSS",
"Strapi CMS",
"Elementor",
"GraphQL",
"TypeScript",
"CI/CD",
"PWA Development",
"AstroJS",
"React Testing Library"
]
},
{
"id": "sk3957foopxir2hw4xzxqahh",
"visible": true,
"name": "Backend Development",
"description": "",
"level": 0,
"keywords": [
"Node.js",
"Express.js",
"MongoDB",
"Supabase",
"Firebase",
"Docker",
"FastAPI",
"AWS S3",
"AWS SES"
]
},
{
"id": "d9bddwdj6qreknhk644rm0bs",
"visible": true,
"name": "Leadership and Problem-Solving",
"description": "",
"level": 0,
"keywords": [
"Agile Project Management",
"Conflict Resolution",
"Creative Problem-Solving",
"Decision-Making",
"Effective Communication",
"Adaptability"
]
},
{
"id": "gk4hrky0wnbsbdcmmud48zjh",
"visible": true,
"name": "Other Programming",
"description": "",
"level": 0,
"keywords": [
"Python Scripting",
"PyAutoGUI",
"Git",
"GitHub",
"Selenium",
"Data Analysis",
"Web Scraping",
"Data Cleaning"
]
}
]
},
"custom": {}
},
"metadata": {
"template": "onyx",
"layout": [
[
[
"summary",
"education",
"experience",
"projects",
"references"
],
[
"profiles",
"skills",
"certifications",
"interests",
"languages",
"awards",
"volunteer",
"publications"
]
]
],
"css": {
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {
"margin": 34,
"format": "a4",
"options": {
"breakLine": false,
"pageNumbers": false
}
},
"theme": {
"background": "#ffffff",
"text": "#000000",
"primary": "#475569"
},
"typography": {
"font": {
"family": "IBM Plex Sans",
"subset": "latin",
"variants": [
"regular"
],
"size": 13
},
"lineHeight": 1.75,
"hideIcons": false,
"underlineLinks": true
},
"notes": ""
}
}