Merge pull request #20 from DaKheera47/user-onboarding
User onboarding UI
This commit is contained in:
commit
d58dcbb441
23
README.md
23
README.md
@ -17,20 +17,17 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
# 1. Setup environment
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Run with Docker
|
||||
# 1. Run with Docker
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Access Dashboard
|
||||
# 2. Open the dashboard
|
||||
# http://localhost:3005
|
||||
```
|
||||
|
||||
## Setup
|
||||
Essential variables in `.env`:
|
||||
- `OPENROUTER_API_KEY`: For job scoring and tailoring.
|
||||
- `RXRESUME_EMAIL`/`PASSWORD`: To automate PDF exports.
|
||||
The app will guide you through setup on first launch. The onboarding wizard helps you:
|
||||
- Connect your OpenRouter API key (for AI scoring/tailoring)
|
||||
- Add your RxResume credentials (for PDF export)
|
||||
- Upload your base resume JSON (exported from RxResume)
|
||||
|
||||
## Structure
|
||||
- `/orchestrator`: React frontend + Node.js backend & pipeline.
|
||||
@ -43,14 +40,8 @@ Orchestrator docs here: `documentation/orchestrator.md`
|
||||
|
||||
## 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.
|
||||
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).
|
||||
|
||||
|
||||
@ -14,10 +14,6 @@ services:
|
||||
volumes:
|
||||
# Persist database and generated PDFs
|
||||
- ./data:/app/data
|
||||
# Base resume JSON (read-only)
|
||||
- ./resume-generator/base.json:/app/resume-generator/base.json:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Server config
|
||||
- NODE_ENV=production
|
||||
|
||||
@ -38,7 +38,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane":
|
||||
|
||||
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).
|
||||
- Your tailored summary/headline/skills and selected projects.
|
||||
|
||||
|
||||
@ -1,61 +1,40 @@
|
||||
# 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
|
||||
|
||||
- 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
|
||||
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
|
||||
No environment variables are strictly required to start. Simply run:
|
||||
|
||||
```bash
|
||||
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
|
||||
- API: http://localhost:3005/api
|
||||
- Health: http://localhost:3005/health
|
||||
Open your browser to:
|
||||
- **Dashboard**: http://localhost:3005
|
||||
|
||||
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
|
||||
|
||||
`./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/`
|
||||
|
||||
## 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).
|
||||
- Resume JSON: Stored internally after upload.
|
||||
|
||||
## Updating
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ orchestrator/
|
||||
2. **Set up environment:**
|
||||
```bash
|
||||
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:**
|
||||
|
||||
240
orchestrator/package-lock.json
generated
240
orchestrator/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -27,11 +27,14 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.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-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -48,7 +51,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -75,6 +77,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.19.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@ -27,6 +28,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingGate />
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={pageKey}
|
||||
|
||||
@ -8,7 +8,6 @@ import type {
|
||||
JobsListResponse,
|
||||
PipelineStatusResponse,
|
||||
JobSource,
|
||||
PipelineRun,
|
||||
AppSettings,
|
||||
ResumeProjectsSettings,
|
||||
ResumeProjectCatalogItem,
|
||||
@ -21,6 +20,8 @@ import type {
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
ResumeProfile,
|
||||
ProfileStatusResponse,
|
||||
ValidationResult,
|
||||
} from '../../shared/types';
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
@ -179,6 +180,34 @@ export async function getProfile(): Promise<ResumeProfile> {
|
||||
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: {
|
||||
model?: string | null
|
||||
|
||||
501
orchestrator/src/client/components/OnboardingGate.tsx
Normal file
501
orchestrator/src/client/components/OnboardingGate.tsx
Normal 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>
|
||||
Let’s 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>
|
||||
)
|
||||
}
|
||||
@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
244
orchestrator/src/components/ui/field.tsx
Normal file
244
orchestrator/src/components/ui/field.tsx
Normal 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,
|
||||
}
|
||||
26
orchestrator/src/components/ui/label.tsx
Normal file
26
orchestrator/src/components/ui/label.tsx
Normal 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 }
|
||||
42
orchestrator/src/components/ui/radio-group.tsx
Normal file
42
orchestrator/src/components/ui/radio-group.tsx
Normal 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 }
|
||||
@ -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 "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--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-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--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 {
|
||||
@ -255,6 +188,7 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
@apply bg-background text-foreground antialiased;
|
||||
|
||||
@ -12,6 +12,7 @@ import { webhookRouter } from './routes/webhook.js';
|
||||
import { profileRouter } from './routes/profile.js';
|
||||
import { databaseRouter } from './routes/database.js';
|
||||
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
|
||||
import { onboardingRouter } from './routes/onboarding.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
@ -24,3 +25,4 @@ apiRouter.use('/webhook', webhookRouter);
|
||||
apiRouter.use('/profile', profileRouter);
|
||||
apiRouter.use('/database', databaseRouter);
|
||||
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
|
||||
apiRouter.use('/onboarding', onboardingRouter);
|
||||
|
||||
273
orchestrator/src/server/api/routes/onboarding.test.ts
Normal file
273
orchestrator/src/server/api/routes/onboarding.test.ts
Normal 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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
|
||||
import { resumeDataSchema } from '@shared/rxresume-schema.js';
|
||||
import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js';
|
||||
import { RxResumeClient } from '@server/services/rxresume-client.js';
|
||||
|
||||
export const onboardingRouter = Router();
|
||||
|
||||
type ValidationResponse = {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
async function validateOpenrouter(apiKey?: string | null): Promise<ValidationResponse> {
|
||||
const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || '';
|
||||
if (!key) {
|
||||
return { valid: false, message: 'OpenRouter API key is missing.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/key', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === 'object' && 'error' in payload) {
|
||||
const errorObj = payload.error as { message?: string; code?: number | string };
|
||||
const message = errorObj?.message || '';
|
||||
const code = errorObj?.code ? ` (${errorObj.code})` : '';
|
||||
detail = `${message}${code}`.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { valid: false, message: 'Invalid OpenRouter API key. Check the key and try again.' };
|
||||
}
|
||||
|
||||
const fallback = `OpenRouter returned ${response.status}`;
|
||||
return { valid: false, message: detail || fallback };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'OpenRouter validation failed.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateResumeJson(): Promise<ValidationResponse> {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
if (!fileInfo.isFile() || fileInfo.size === 0) {
|
||||
return { valid: false, message: 'Resume JSON is missing.' };
|
||||
}
|
||||
|
||||
const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = resumeDataSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.';
|
||||
const details = path
|
||||
? `Field "${path}": ${baseMessage}`
|
||||
: baseMessage;
|
||||
return { valid: false, message: details };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRxresume(email?: string | null, password?: string | null): Promise<ValidationResponse> {
|
||||
const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || '';
|
||||
const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || '';
|
||||
|
||||
if (!rxEmail || !rxPassword) {
|
||||
return { valid: false, message: 'RxResume credentials are missing.' };
|
||||
}
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(rxEmail, rxPassword);
|
||||
|
||||
if (result.ok) {
|
||||
return { valid: true, message: null };
|
||||
}
|
||||
|
||||
const normalizedMessage = result.message?.toLowerCase() ?? '';
|
||||
if (result.status === 401 || normalizedMessage.includes('invalidcredentials')) {
|
||||
return { valid: false, message: 'Invalid RxResume credentials. Check your email and password and try again.' };
|
||||
}
|
||||
|
||||
const message = result.message || `RxResume validation failed (HTTP ${result.status})`;
|
||||
return { valid: false, message };
|
||||
}
|
||||
|
||||
onboardingRouter.post('/validate/openrouter', async (req: Request, res: Response) => {
|
||||
const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined;
|
||||
const result = await validateOpenrouter(apiKey);
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
|
||||
onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response) => {
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email : undefined;
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : undefined;
|
||||
const result = await validateRxresume(email, password);
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
|
||||
onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
|
||||
const result = await validateResumeJson();
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { writeFile, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Profile API routes', () => {
|
||||
@ -16,7 +18,29 @@ describe.sequential('Profile API routes', () => {
|
||||
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 () => {
|
||||
// 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 body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
@ -24,10 +48,206 @@ describe.sequential('Profile API routes', () => {
|
||||
});
|
||||
|
||||
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 body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,14 +1,30 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { mkdir, stat, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
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();
|
||||
|
||||
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
|
||||
*/
|
||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
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) => {
|
||||
try {
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: null });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (error) {
|
||||
@ -30,3 +50,59 @@ profileRouter.get('/', async (req: Request, res: Response) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -74,7 +74,7 @@ export function createApp() {
|
||||
const authGuard = createBasicAuthGuard();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// Logging middleware
|
||||
app.use((req, res, next) => {
|
||||
|
||||
@ -52,8 +52,6 @@ export async function generatePdf(
|
||||
): Promise<PdfResult> {
|
||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||
|
||||
const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json');
|
||||
|
||||
try {
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
|
||||
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json');
|
||||
|
||||
let cachedProfile: any = null;
|
||||
let cachedProfilePath: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from base.json.
|
||||
* Get the base resume profile from resume.json.
|
||||
* Caches the result since it doesn't change often.
|
||||
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
||||
* @param forceRefresh Force reload from disk.
|
||||
|
||||
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal file
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
@ -0,0 +1,213 @@
|
||||
// rxresume-client.ts
|
||||
// Minimal client for https://v4.rxresu.me
|
||||
// Currently only verifyCredentials is in use; other methods are reserved for future use.
|
||||
//
|
||||
// NOTE (critical): Credentials should never be hardcoded or logged.
|
||||
|
||||
type AnyObj = Record<string, unknown>;
|
||||
|
||||
export type VerifyResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
// Message is best-effort; server responses vary.
|
||||
message?: string;
|
||||
// Some APIs include error codes/details.
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export class RxResumeClient {
|
||||
constructor(private readonly baseURL = 'https://v4.rxresu.me') { }
|
||||
|
||||
/**
|
||||
* Verify a username/password combo WITHOUT persisting a logged-in session.
|
||||
*
|
||||
* Reality check:
|
||||
* - Most sites only expose "verify" by attempting login.
|
||||
* - This method does a stateless request to test credentials.
|
||||
*/
|
||||
static async verifyCredentials(
|
||||
identifier: string,
|
||||
password: string,
|
||||
baseURL = 'https://v4.rxresu.me'
|
||||
): Promise<VerifyResult> {
|
||||
try {
|
||||
const res = await fetch(`${baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
// No credentials mode - we don't want to persist cookies
|
||||
});
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
// Best-effort message extraction
|
||||
let data: AnyObj = {};
|
||||
try {
|
||||
const text = await res.text();
|
||||
data = text ? (JSON.parse(text) as AnyObj) : {};
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
const message =
|
||||
(typeof data === 'string' ? data : undefined) ??
|
||||
(typeof data?.message === 'string' ? data.message : undefined) ??
|
||||
(typeof data?.error === 'string' ? data.error : undefined) ??
|
||||
(typeof data?.statusMessage === 'string' ? data.statusMessage : undefined);
|
||||
|
||||
return { ok: false, status: res.status, message, details: data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RESERVED FOR FUTURE USE
|
||||
// The following methods support full resume lifecycle management via the
|
||||
// RxResume API. They are not currently used but are kept for future features.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Returns the auth token on success.
|
||||
*/
|
||||
async login(identifier: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Login failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AnyObj;
|
||||
// The API may return the token in different ways
|
||||
const token =
|
||||
data?.accessToken ??
|
||||
data?.access_token ??
|
||||
data?.token ??
|
||||
(data?.data as AnyObj)?.accessToken ??
|
||||
(data?.data as AnyObj)?.token;
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error(
|
||||
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/resume/import
|
||||
*/
|
||||
async create(resumeData: unknown, token: string): Promise<string> {
|
||||
const res = await fetch(`${this.baseURL}/api/resume/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ data: resumeData }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Create failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
const id =
|
||||
d?.id ??
|
||||
(d?.data as AnyObj)?.id ??
|
||||
(d?.resume as AnyObj)?.id ??
|
||||
(d?.result as AnyObj)?.id ??
|
||||
(d?.payload as AnyObj)?.id ??
|
||||
((d?.data as AnyObj)?.resume as AnyObj)?.id;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw new Error(
|
||||
`Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/resume/print/:id
|
||||
* Returns the print URL from the response.
|
||||
*/
|
||||
async print(resumeId: string, token: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${this.baseURL}/api/resume/print/${encodeURIComponent(resumeId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Print failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
const url =
|
||||
d?.url ??
|
||||
d?.href ??
|
||||
(d?.data as AnyObj)?.url ??
|
||||
(d?.data as AnyObj)?.href ??
|
||||
(d?.result as AnyObj)?.url ??
|
||||
(d?.result as AnyObj)?.href;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error(
|
||||
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/resume/:id
|
||||
*/
|
||||
async delete(resumeId: string, token: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${this.baseURL}/api/resume/${encodeURIComponent(resumeId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,10 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
// Parallelize slow operations
|
||||
const [overrides, profile] = await Promise.all([
|
||||
settingsRepo.getAllSettings(),
|
||||
getProfile(),
|
||||
getProfile().catch((error) => {
|
||||
console.warn('Failed to load base resume profile for settings:', error);
|
||||
return {};
|
||||
}),
|
||||
]);
|
||||
|
||||
const envSettings = await getEnvSettingsData(overrides);
|
||||
|
||||
@ -11,6 +11,7 @@ export type FilterKeys<T, Condition> = {
|
||||
export const idSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.length(24)
|
||||
.describe("Unique identifier for the item (CUID2 format)");
|
||||
|
||||
export const itemSchema = z.object({
|
||||
|
||||
@ -331,6 +331,16 @@ export interface ResumeProfile {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProfileStatusResponse {
|
||||
exists: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
|
||||
@ -3,24 +3,5 @@ import type { Config } from "tailwindcss";
|
||||
export default {
|
||||
darkMode: "class",
|
||||
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: [],
|
||||
} satisfies Config;
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
@ -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 & 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 & 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 & 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 & 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 & 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": ""
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user