[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
) {
+ return (
+
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [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 (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+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 (
+
+ {errors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/orchestrator/src/components/ui/label.tsx b/orchestrator/src/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/orchestrator/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/orchestrator/src/components/ui/radio-group.tsx b/orchestrator/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..9d3a26e
--- /dev/null
+++ b/orchestrator/src/components/ui/radio-group.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/orchestrator/src/index.css b/orchestrator/src/index.css
index a9bb87e..cca4e24 100644
--- a/orchestrator/src/index.css
+++ b/orchestrator/src/index.css
@@ -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;
@@ -276,4 +210,4 @@
.page-exit-active {
opacity: 0;
transition: opacity 75ms ease-in;
-}
\ No newline at end of file
+}
diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts
index ffb826c..33befd9 100644
--- a/orchestrator/src/server/api/routes.ts
+++ b/orchestrator/src/server/api/routes.ts
@@ -12,6 +12,7 @@ import { webhookRouter } from './routes/webhook.js';
import { profileRouter } from './routes/profile.js';
import { databaseRouter } from './routes/database.js';
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
+import { onboardingRouter } from './routes/onboarding.js';
export const apiRouter = Router();
@@ -24,3 +25,4 @@ apiRouter.use('/webhook', webhookRouter);
apiRouter.use('/profile', profileRouter);
apiRouter.use('/database', databaseRouter);
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
+apiRouter.use('/onboarding', onboardingRouter);
diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts
new file mode 100644
index 0000000..976e3b6
--- /dev/null
+++ b/orchestrator/src/server/api/routes/onboarding.test.ts
@@ -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: '',
+ },
+ };
+}
diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts
new file mode 100644
index 0000000..723c620
--- /dev/null
+++ b/orchestrator/src/server/api/routes/onboarding.ts
@@ -0,0 +1,124 @@
+import { Router, Request, Response } from 'express';
+import { readFile, stat } from 'fs/promises';
+
+import { resumeDataSchema } from '@shared/rxresume-schema.js';
+import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js';
+import { RxResumeClient } from '@server/services/rxresume-client.js';
+
+export const onboardingRouter = Router();
+
+type ValidationResponse = {
+ valid: boolean;
+ message: string | null;
+};
+
+async function validateOpenrouter(apiKey?: string | null): Promise {
+ const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || '';
+ if (!key) {
+ return { valid: false, message: 'OpenRouter API key is missing.' };
+ }
+
+ try {
+ const response = await fetch('https://openrouter.ai/api/v1/key', {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${key}`,
+ },
+ });
+
+ if (!response.ok) {
+ let detail = '';
+ try {
+ const payload = await response.json();
+ if (payload && typeof payload === 'object' && 'error' in payload) {
+ const errorObj = payload.error as { message?: string; code?: number | string };
+ const message = errorObj?.message || '';
+ const code = errorObj?.code ? ` (${errorObj.code})` : '';
+ detail = `${message}${code}`.trim();
+ }
+ } catch {
+ // ignore JSON parse errors
+ }
+
+ if (response.status === 401) {
+ return { valid: false, message: 'Invalid OpenRouter API key. Check the key and try again.' };
+ }
+
+ const fallback = `OpenRouter returned ${response.status}`;
+ return { valid: false, message: detail || fallback };
+ }
+
+ return { valid: true, message: null };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'OpenRouter validation failed.';
+ return { valid: false, message };
+ }
+}
+
+async function validateResumeJson(): Promise {
+ try {
+ const fileInfo = await stat(DEFAULT_PROFILE_PATH);
+ if (!fileInfo.isFile() || fileInfo.size === 0) {
+ return { valid: false, message: 'Resume JSON is missing.' };
+ }
+
+ const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8');
+ const parsed = JSON.parse(raw);
+ const result = resumeDataSchema.safeParse(parsed);
+ if (!result.success) {
+ const issue = result.error.issues[0];
+ const path = issue?.path?.join('.') || '';
+ const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.';
+ const details = path
+ ? `Field "${path}": ${baseMessage}`
+ : baseMessage;
+ return { valid: false, message: details };
+ }
+
+ return { valid: true, message: null };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
+ return { valid: false, message };
+ }
+}
+
+async function validateRxresume(email?: string | null, password?: string | null): Promise {
+ const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || '';
+ const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || '';
+
+ if (!rxEmail || !rxPassword) {
+ return { valid: false, message: 'RxResume credentials are missing.' };
+ }
+
+ const result = await RxResumeClient.verifyCredentials(rxEmail, rxPassword);
+
+ if (result.ok) {
+ return { valid: true, message: null };
+ }
+
+ const normalizedMessage = result.message?.toLowerCase() ?? '';
+ if (result.status === 401 || normalizedMessage.includes('invalidcredentials')) {
+ return { valid: false, message: 'Invalid RxResume credentials. Check your email and password and try again.' };
+ }
+
+ const message = result.message || `RxResume validation failed (HTTP ${result.status})`;
+ return { valid: false, message };
+}
+
+onboardingRouter.post('/validate/openrouter', async (req: Request, res: Response) => {
+ const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined;
+ const result = await validateOpenrouter(apiKey);
+ res.json({ success: true, data: result });
+});
+
+onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response) => {
+ const email = typeof req.body?.email === 'string' ? req.body.email : undefined;
+ const password = typeof req.body?.password === 'string' ? req.body.password : undefined;
+ const result = await validateRxresume(email, password);
+ res.json({ success: true, data: result });
+});
+
+onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
+ const result = await validateResumeJson();
+ res.json({ success: true, data: result });
+});
diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts
index 52ec3e4..db71386 100644
--- a/orchestrator/src/server/api/routes/profile.test.ts
+++ b/orchestrator/src/server/api/routes/profile.test.ts
@@ -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: '',
+ },
+ };
+}
diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts
index e802cd0..c75bd31 100644
--- a/orchestrator/src/server/api/routes/profile.ts
+++ b/orchestrator/src/server/api/routes/profile.ts
@@ -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 {
+ 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).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 });
+ }
+});
diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts
index 4f39077..1cd20e7 100644
--- a/orchestrator/src/server/app.ts
+++ b/orchestrator/src/server/app.ts
@@ -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) => {
diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts
index 2037672..65bfefd 100644
--- a/orchestrator/src/server/services/pdf.ts
+++ b/orchestrator/src/server/services/pdf.ts
@@ -52,8 +52,6 @@ export async function generatePdf(
): Promise {
console.log(`📄 Generating PDF for job ${jobId}...`);
- const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json');
-
try {
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts
index f108658..dd935dd 100644
--- a/orchestrator/src/server/services/profile.ts
+++ b/orchestrator/src/server/services/profile.ts
@@ -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.
diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume-client.test.ts
new file mode 100644
index 0000000..59123dc
--- /dev/null
+++ b/orchestrator/src/server/services/rxresume-client.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume-client.ts
new file mode 100644
index 0000000..ca15e14
--- /dev/null
+++ b/orchestrator/src/server/services/rxresume-client.ts
@@ -0,0 +1,213 @@
+// rxresume-client.ts
+// Minimal client for https://v4.rxresu.me
+// Currently only verifyCredentials is in use; other methods are reserved for future use.
+//
+// NOTE (critical): Credentials should never be hardcoded or logged.
+
+type AnyObj = Record;
+
+export type VerifyResult =
+ | { ok: true }
+ | {
+ ok: false;
+ status: number;
+ // Message is best-effort; server responses vary.
+ message?: string;
+ // Some APIs include error codes/details.
+ details?: unknown;
+ };
+
+export class RxResumeClient {
+ constructor(private readonly baseURL = 'https://v4.rxresu.me') { }
+
+ /**
+ * Verify a username/password combo WITHOUT persisting a logged-in session.
+ *
+ * Reality check:
+ * - Most sites only expose "verify" by attempting login.
+ * - This method does a stateless request to test credentials.
+ */
+ static async verifyCredentials(
+ identifier: string,
+ password: string,
+ baseURL = 'https://v4.rxresu.me'
+ ): Promise {
+ try {
+ const res = await fetch(`${baseURL}/api/auth/login`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ identifier, password }),
+ // No credentials mode - we don't want to persist cookies
+ });
+
+ if (res.ok) return { ok: true };
+
+ // Best-effort message extraction
+ let data: AnyObj = {};
+ try {
+ const text = await res.text();
+ data = text ? (JSON.parse(text) as AnyObj) : {};
+ } catch {
+ // Ignore JSON parse errors
+ }
+
+ const message =
+ (typeof data === 'string' ? data : undefined) ??
+ (typeof data?.message === 'string' ? data.message : undefined) ??
+ (typeof data?.error === 'string' ? data.error : undefined) ??
+ (typeof data?.statusMessage === 'string' ? data.statusMessage : undefined);
+
+ return { ok: false, status: res.status, message, details: data };
+ } catch (error) {
+ return {
+ ok: false,
+ status: 0,
+ message: error instanceof Error ? error.message : 'Network error',
+ details: error,
+ };
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // RESERVED FOR FUTURE USE
+ // The following methods support full resume lifecycle management via the
+ // RxResume API. They are not currently used but are kept for future features.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * POST /api/auth/login
+ * Returns the auth token on success.
+ */
+ async login(identifier: string, password: string): Promise {
+ const res = await fetch(`${this.baseURL}/api/auth/login`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ identifier, password }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Login failed: HTTP ${res.status} ${text}`);
+ }
+
+ const data = (await res.json()) as AnyObj;
+ // The API may return the token in different ways
+ const token =
+ data?.accessToken ??
+ data?.access_token ??
+ data?.token ??
+ (data?.data as AnyObj)?.accessToken ??
+ (data?.data as AnyObj)?.token;
+
+ if (!token || typeof token !== 'string') {
+ throw new Error(
+ `Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}`
+ );
+ }
+
+ return token;
+ }
+
+ /**
+ * POST /api/resume/import
+ */
+ async create(resumeData: unknown, token: string): Promise {
+ const res = await fetch(`${this.baseURL}/api/resume/import`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ data: resumeData }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Create failed: HTTP ${res.status} ${text}`);
+ }
+
+ const d = (await res.json()) as AnyObj;
+ const id =
+ d?.id ??
+ (d?.data as AnyObj)?.id ??
+ (d?.resume as AnyObj)?.id ??
+ (d?.result as AnyObj)?.id ??
+ (d?.payload as AnyObj)?.id ??
+ ((d?.data as AnyObj)?.resume as AnyObj)?.id;
+
+ if (!id || typeof id !== 'string') {
+ throw new Error(
+ `Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(', ')}`
+ );
+ }
+
+ return id;
+ }
+
+ /**
+ * GET /api/resume/print/:id
+ * Returns the print URL from the response.
+ */
+ async print(resumeId: string, token: string): Promise {
+ const res = await fetch(
+ `${this.baseURL}/api/resume/print/${encodeURIComponent(resumeId)}`,
+ {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Print failed: HTTP ${res.status} ${text}`);
+ }
+
+ const d = (await res.json()) as AnyObj;
+ const url =
+ d?.url ??
+ d?.href ??
+ (d?.data as AnyObj)?.url ??
+ (d?.data as AnyObj)?.href ??
+ (d?.result as AnyObj)?.url ??
+ (d?.result as AnyObj)?.href;
+
+ if (!url || typeof url !== 'string') {
+ throw new Error(
+ `Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`
+ );
+ }
+
+ return url;
+ }
+
+ /**
+ * DELETE /api/resume/:id
+ */
+ async delete(resumeId: string, token: string): Promise {
+ const res = await fetch(
+ `${this.baseURL}/api/resume/${encodeURIComponent(resumeId)}`,
+ {
+ method: 'DELETE',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ if (!res.ok && res.status !== 204) {
+ const text = await res.text();
+ throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
+ }
+ }
+}
diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts
index fd82c7e..76e47b1 100644
--- a/orchestrator/src/server/services/settings.ts
+++ b/orchestrator/src/server/services/settings.ts
@@ -11,7 +11,10 @@ export async function getEffectiveSettings(): Promise {
// 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);
diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts
index b487960..10f2c3a 100644
--- a/orchestrator/src/shared/rxresume-schema.ts
+++ b/orchestrator/src/shared/rxresume-schema.ts
@@ -11,6 +11,7 @@ export type FilterKeys = {
export const idSchema = z
.string()
.cuid2()
+ .length(24)
.describe("Unique identifier for the item (CUID2 format)");
export const itemSchema = z.object({
diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts
index fbafbf6..440d424 100644
--- a/orchestrator/src/shared/types.ts
+++ b/orchestrator/src/shared/types.ts
@@ -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;
diff --git a/orchestrator/tailwind.config.ts b/orchestrator/tailwind.config.ts
index 64f9d9b..57b3bcc 100644
--- a/orchestrator/tailwind.config.ts
+++ b/orchestrator/tailwind.config.ts
@@ -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;
-
diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts
index 663ef69..e11b661 100644
--- a/orchestrator/vite.config.ts
+++ b/orchestrator/vite.config.ts
@@ -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',
diff --git a/resume-generator/base.bak.json b/resume-generator/base.bak.json
deleted file mode 100644
index b0f0195..0000000
--- a/resume-generator/base.bak.json
+++ /dev/null
@@ -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": "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
"
- },
- "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": "Relevant Modules: Web Applications, Algorithms & Data Structures, Game Development, Databases, Software Engineering (Agile group project)
",
- "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": "Maths: A
Computer Science: B
Physics: C
Chemistry: E
",
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "pm4r5hngvv1w4mc79o22irfx",
- "visible": false,
- "institution": " ",
- "studyType": "",
- "area": "GCSEs",
- "score": "",
- "date": "",
- "summary": "English: A*
Computer Science: A*
Urdu: A
Islamiat: A
Pakistan Studies: A
Biology: A
Chemistry: A
Physics: A
Maths: A
",
- "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": "Implemented front-end features and fixes 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
Improved reliability of the Cypress end-to-end test suite by diagnosing flaky tests, adding new E2E coverage, and participating in focused \u201ctest fest\u201d events ahead of major feature releases
Collaborated with cross-functional teams (like the Design System, platform teams) by raising well-scoped bugs , augmenting existing tickets with reproduction steps and context, and aligning on shared component and API changes
Helped strengthen team processes by running weekly stand-ups and retrospectives, organising a ticket-scoping meeting, and participating in technical reviews & ADR discussions (e.g. standardising error handling and planning clash data streaming)
",
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "lhw25d7gf32wgdfpsktf6e0x",
- "visible": true,
- "company": "Mirage",
- "position": "Co-Founder & Lead Developer",
- "location": "",
- "date": "December 2019 to Present",
- "summary": "Delivered 10+ production websites and webapps for small and medium size clients (e.g. Indus Marine Services, Mumtaz Urdu), from initial scoping to deployment and handover
Built with modern web stacks (Next.js, Node/Express, Tailwind, Strapi, WordPress/Elementor where appropriate), setting up CI/CD and hosting
Led a small team of four developers , handling code reviews, task breakdown, and client communication
",
- "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": "Academic Support and Leadership: 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.
Collaborative Learning Environment: 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.
",
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "a1bg5d8gp8sulf91xzdcsiaq",
- "visible": true,
- "company": "Research and Knowledge Exchange Institute",
- "position": "Undergraduate Research Intern (HCI & EdTech)",
- "location": "",
- "date": "Summer 2024",
- "summary": "Built a mouse \u201ctorch-reveal\u201d web app (Astro ) to approximate eye-tracking; ran on-campus studies with Revoe Learning Academy pupils\u20141 eye-tracked, 9 using my app.
Logged cursor paths, dwell time, and reveal order; delivered setup notes for staff to run sessions independently.
Developed a Questionnaire Randomiser (Next.js): selectable response metrics (smileys / numbers / stars ), configurable randomisation strategies, and ZIP export of per-student PDFs ready for print.
Extras: lightweight analytics for comparison with the eye-tracking baseline; optional CSV/JSON data export.
",
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "tx32suzrg2bs5eumcbjei4ns",
- "visible": false,
- "company": "University of Lancashire",
- "position": "Student Ambassador",
- "location": "Preston, UK",
- "date": "July 2023 - Present",
- "summary": "Diverse Role Engagement: Actively engaged in various tasks, from guiding tours to assisting on open days, demonstrating adaptability and organizational skills.
Campus Culture Promotion: Contributed to enhancing the university\u2019s inclusive campus atmosphere, showcasing the university's vibrant community to prospective students.
",
- "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": "Next.js Implementation for Enhanced SEO: Utilized Next.js to optimize the website for search engines, significantly improving its online visibility and user engagement.
Strapi Backend Integration: Streamlined content management by implementing a Strapi backend, enhancing the efficiency and scalability of the website's content updates.
Responsive Design with Tailwind CSS: Employed Tailwind CSS for a utility-first approach, ensuring the website's responsiveness and seamless user experience across various devices.
Continuous Deployment Pipeline Establishment: Developed a continuous deployment pipeline, ensuring real-time updates and maintaining high performance and reliability of the website.
Optimized Web Performance: Focused on optimizing web performance by efficiently loading images and managing JavaScript bundles, leading to a faster and more efficient user experience.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://atoro.promirage.com"
- }
- },
- {
- "id": "ncxgdjjky54gh59iz2t1xi1v",
- "visible": false,
- "name": "Stellar Consultancy",
- "description": "Lead Developer",
- "date": "April 2023",
- "summary": "WordPress and Elementor Integration: Expertly utilized WordPress with Elementor to build a robust content management system, enhancing the website's scalability and user interaction capabilities.
Client Engagement and Trust Building: Implemented features to showcase client testimonials, effectively building trust and displaying the success of previous project engagements.
Intuitive Design and User Engagement: Focused on intuitive page design and structuring, streamlining site maintenance and content updates, thereby enhancing user engagement.
Effective Call-to-Actions: Crafted clear call-to-actions and provided essential contact information, significantly improving user interaction and conversion rates.
Portfolio Display for Business Showcase: Presented past work and services offered through a comprehensive portfolio display, allowing visitors to assess the quality and impact of Stellar Consultancy's services.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://stellarconsultancy.ca"
- }
- },
- {
- "id": "tcecguinuctb8mu2xqrn97m8",
- "visible": true,
- "name": "Mumtaz Urdu",
- "description": "Developer",
- "date": "July 2022",
- "summary": "Server-Rendered Web Application Development : Created the Mumtaz Urdu platform with Next.js to optimize server-side rendering for enhanced SEO and performance.
UI Development with Tailwind CSS : Implemented utility-first Tailwind CSS, ensuring rapid, responsive design for a seamless user interface.
Scalable Storage Solution : Integrated scalable Amazon S3 storage, supporting the application's growth and robust data management.
Progressive Web App Implementation : Developed PWA features for Mumtaz Urdu, offering users native-like mobile access and increased engagement.
High Traffic Data Management : 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.
Test-Driven Development : Embraced TDD practices to ensure reliable and high-quality code, facilitating regular testing throughout the development process for continuous improvement.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://www.mumtazurdu.com/"
- }
- },
- {
- "id": "to47h749kaj6t02j3f9kprxq",
- "visible": false,
- "name": "PyScreeze",
- "description": "Open Source Contribution",
- "date": "January 2022",
- "summary": "Innovative Feature Implementation: Implemented the locateCenterOnScreenNear function for PyScreeze, enhancing the library's functionality by enabling precise image location near a specified point on the screen.
Open Source Contribution: Marked my debut in open-source contributions with this significant addition to PyScreeze, showcasing my initiative and ability to contribute effectively to community-driven projects.
Collaborative Development and Recognition: Collaborated with the project's maintainer, asweigart, to refine and integrate the function into the main codebase, receiving recognition for this valuable contribution to the project.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://github.com/asweigart/pyscreeze/pull/79"
- }
- },
- {
- "id": "gt7yq82ulor5hmmutdhuvfo1",
- "visible": false,
- "name": "Threegency",
- "description": "Lead Developer",
- "date": "February 2023",
- "summary": "Framework : Utilized Next.js to build a server-rendered React website, enhancing SEO and ensuring optimal performance.
Styling : Employed Tailwind CSS for utility-first styling, facilitating rapid UI development.
Content Management : Leveraged Strapi as a CMS, enabling streamlined content updates and administration.
Data Handling : Utilized GraphQL for data handling, ensuring efficient and flexible data retrieval.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://www.threegency.com"
- }
- },
- {
- "id": "c8fcu3nz541a4d5zcurx6b8c",
- "visible": false,
- "name": "AutoClass",
- "description": "GUI Automation",
- "date": "November 2021",
- "summary": "Framework : Written in Python, leveraging the versatility and ease-of-use of the language.
Automation Library : Utilized PyAutoGUI for automating user interactions, enhancing the utility of the application.
Iterative Improvement : Progressively refined over a year, demonstrating a commitment to robustness and reliability.
Project Purpose : Developed to automate the process of joining Zoom classes, alleviating the repetitive morning routine.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": "https://github.com/DaKheera47/autoclass"
- }
- },
- {
- "id": "rv23bgibq6bye6rujmcx1ygc",
- "visible": false,
- "name": "Meet Link Generator",
- "description": "GUI Automation",
- "date": "January 2022",
- "summary": "Functionality : 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.
Optimized Automation : The final product uses Python with PyAutoGUI for efficient and rapid creation of new Google Meet links.
Speed and Efficiency : Drastically improved performance, finally achieving the link generation time to under 1 second per link, limited only by internet speed.
Interface Interaction : Utilizes the Google Meet homepage's features for quicker link generation, avoiding full page refreshes for speed.
",
- "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": "Backend Development with PHP and MySQL: Developed the backend for a Student\u2019s Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.
User Authentication and Session Management: Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.
Dynamic Content Display from Database: Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.
Advanced Search and Personalization Features: Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "ov4lkbc1vl169ynfnj91m1lm",
- "visible": false,
- "name": "Square About",
- "description": "",
- "date": "UCLan Year 1",
- "summary": "Advanced 3D Game Development: Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.
Dynamic Gameplay Elements: Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.
Interactive Game Controls: Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.
Strategic Game Mechanics: Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "s3r37gdr0oa84a6dp6r5nl58",
- "visible": false,
- "name": "Car Smash",
- "description": "",
- "date": "UCLan Year 1",
- "summary": "3D Car Smash Game Development: Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.
Collision Detection Mechanics: Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.
Dynamic Game States and Camera Views: Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.
Enhanced Player Interaction: Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "gylzkvl103m9s7ywag4xpdy4",
- "visible": false,
- "name": "Tweet Filter",
- "description": "",
- "date": "UCLan Year 1",
- "summary": "Tweet Filtration System: Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.
Advanced Text Manipulation: Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.
Output Generation: Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.
Algorithm Optimization: Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "enav754zxhuc9uycbb83s94q",
- "visible": false,
- "name": "Burger Ordering App",
- "description": "",
- "date": "UCLan Year 1",
- "summary": "Interactive Console Application: Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.
Complex Logic Implementation: Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.
Data Handling and User Input: Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.
Readable and Maintainable Code: Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "hl6jgeswr01tlul3iwoat05d",
- "visible": false,
- "name": "LinkLander",
- "description": "Android Studio, Kotlin",
- "date": "December 2023 - Ongoing",
- "summary": "Innovative Android Utility: Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.
User-Centric Design: Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.
Simplicity and Efficiency: Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.
Technical Proficiency in Kotlin: Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "v4s0ljbiiio198y8l1wl0ym6",
- "visible": false,
- "name": "AR App Development with AGILE",
- "description": "Unity, C#",
- "date": "October 2023 - Ongoing",
- "summary": "Agile Development in Action : 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.
Mobile AR Application Prototype : Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.
Collaborative Software Engineering : Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.
Presentation and Critical Analysis : Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.
",
- "keywords": [],
- "url": {
- "label": "",
- "href": ""
- }
- },
- {
- "id": "fwxrq682hqrj1y76rmziqrbk",
- "visible": true,
- "name": "Indus Marine Services",
- "description": "System Design & Development",
- "date": "May 2022 - Ongoing",
- "summary": "Induction System for Marine Services : Designed & developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.
Admin-Centric Functionality : Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements
Client Engagement Interface : Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.
Robust Tech Stack Integration : Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.
",
- "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": "Innovative AI Development : 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.
Data-Driven Content Strategy : Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.
Astro for Enhanced Performance : Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience
React for Responsive Interaction : 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.
",
- "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": "Second Place in UCLan Hackathon : 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.
TRPC for End-to-End Type Safety : Utilized TRPC to ensure end-to-end type safety, enhancing the app's reliability and streamlining the development process.
Supabase Backend Integration : Implemented Supabase as a backend solution, providing a robust and scalable database for managing attendance data efficiently.
Amazon SES and OAuth Integration : Integrated Amazon SES for email notifications and OAuth for secure Google login, improving user experience and communication.
",
- "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": "Full-Stack Integration: Wired up backend services to a responsive frontend, enabling real-time exploration of Kepler/K2/TESS catalogs and smooth model-scoring UX.
Data Harmonization Pipeline: Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by 60%+ during the hack.
Analytics UI & Upload Flow: Built an upload \u2192 validate \u2192 score workflow and a clear results dashboard so researchers can triage candidates in minutes, not hours.
Delivery Under Pressure: Coordinated a 5-person multidisciplinary team to ship a working web app in 48 hours , with demo-ready reliability for judging.
",
- "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": "Self-Hosted Strength Analytics Platform: Developed strong-statistics , an open-source web app that visualizes detailed workout analytics from the Strong and Hevy fitness app, giving users local control of their training data.
Full-Stack Architecture: Built a modular stack with FastAPI , Next.js , Tailwind CSS , and SQLite , deployed via Docker Compose for seamless self-hosting and persistent local data storage.
Active Open-Source Ecosystem: Published on GitHub with community engagement from global users \u2014 external contributors opened feature requests and bug reports, validating real-world adoption and reliability.
Continuous Personal Use & Maintenance: Regularly updated and used in live deployment at lifting.dakheera47.com , tracking hundreds of sets over time with persistent analytics and performance trends.
",
- "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": ""
- }
-}
\ No newline at end of file