orchestrator initial commit...

This commit is contained in:
DaKheera47 2025-12-11 22:31:59 +00:00
parent 7ab024efda
commit 1b082a3eb6
46 changed files with 9792 additions and 5 deletions

View File

@ -19,7 +19,7 @@ const crawler = new PlaywrightCrawler({
maxRequestsPerCrawl: 20,
// Add delay between requests to slow down the process
minConcurrency: 1,
maxConcurrency: 2,
maxConcurrency: 5,
navigationTimeoutSecs: 60,
// Add delay between requests (in milliseconds)
// requestHandlerTimeoutSecs: 50,
@ -30,7 +30,7 @@ const crawler = new PlaywrightCrawler({
launchContext: {
launcher: firefox,
launchOptions: await launchOptions({
headless: false,
headless: true,
// block_images: true,
// Pass your own Camoufox parameters here...
// block_images: true,

21
orchestrator/.env.example Normal file
View File

@ -0,0 +1,21 @@
# Server
PORT=3001
# OpenRouter API (for AI features)
OPENROUTER_API_KEY=your_openrouter_api_key_here
MODEL=openai/gpt-4o-mini
# Notion integration (optional)
NOTION_API_KEY=
NOTION_DATABASE_ID=
# Webhook security (optional)
WEBHOOK_SECRET=
# Pipeline configuration
PIPELINE_TOP_N=10
PIPELINE_MIN_SCORE=50
# RXResume credentials (for PDF generation)
RXRESUME_EMAIL=
RXRESUME_PASSWORD=

26
orchestrator/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# Data (local database and generated files)
data/
# Environment
.env
.env.local
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*

123
orchestrator/README.md Normal file
View File

@ -0,0 +1,123 @@
# Job Ops Orchestrator
A unified orchestrator for the job application pipeline. Discovers jobs, scores them for suitability, generates tailored resumes, and provides a UI to manage applications.
## Architecture
```
orchestrator/
├── src/
│ ├── server/ # Express backend
│ │ ├── api/ # REST API routes
│ │ ├── db/ # SQLite + Drizzle ORM
│ │ ├── pipeline/ # Orchestration logic
│ │ ├── repositories/ # Data access layer
│ │ └── services/ # Integrations (crawler, AI, PDF)
│ ├── client/ # React frontend
│ │ ├── api/ # API client
│ │ ├── components/ # UI components
│ │ └── styles/ # CSS design system
│ └── shared/ # Shared types
├── data/ # SQLite DB + generated PDFs (gitignored)
└── public/ # Static assets
```
## Setup
1. **Install dependencies:**
```bash
cd orchestrator
npm install
```
2. **Set up environment:**
```bash
cp .env.example .env
# Edit .env with your API keys
```
3. **Initialize database:**
```bash
npm run db:migrate
```
4. **Start development server:**
```bash
npm run dev
```
This starts:
- Backend API at `http://localhost:3001`
- Frontend at `http://localhost:5173`
## API Endpoints
### Jobs
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/jobs` | List all jobs (filter with `?status=ready,discovered`) |
| GET | `/api/jobs/:id` | Get single job |
| PATCH | `/api/jobs/:id` | Update job |
| POST | `/api/jobs/:id/process` | Generate resume for job |
| POST | `/api/jobs/:id/apply` | Mark as applied + sync to Notion |
| POST | `/api/jobs/:id/reject` | Mark as rejected |
### Pipeline
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/pipeline/status` | Get pipeline status |
| GET | `/api/pipeline/runs` | Get recent pipeline runs |
| POST | `/api/pipeline/run` | Trigger pipeline manually |
| POST | `/api/webhook/trigger` | Webhook for n8n (use `WEBHOOK_SECRET`) |
## Daily Flow
1. **17:00 - n8n triggers pipeline:**
- Calls `POST /api/webhook/trigger`
- Pipeline crawls Gradcracker
- Scores jobs with AI
- Generates tailored resumes for top 10
2. **You review in the UI:**
- See jobs at `http://localhost:5173`
- "Ready" tab shows jobs with generated PDFs
- Click "View Job" to open application
- Download PDF and apply manually
- Click "Mark Applied" → syncs to Notion
## n8n Setup
Create a workflow with:
1. **Schedule Trigger** - Every day at 17:00
2. **HTTP Request:**
- Method: POST
- URL: `http://localhost:3001/api/webhook/trigger`
- Headers: `Authorization: Bearer YOUR_WEBHOOK_SECRET`
## Development
```bash
# Run just the server
npm run dev:server
# Run just the client
npm run dev:client
# Run the pipeline manually
npm run pipeline:run
# Build for production
npm run build
npm start
```
## Tech Stack
- **Backend:** Express, TypeScript, Drizzle ORM, SQLite
- **Frontend:** React, Vite, CSS (custom design system)
- **AI:** OpenRouter API (GPT-4o-mini)
- **PDF Generation:** Wraps existing Python RXResume automation
- **Job Crawling:** Wraps existing TypeScript Crawlee crawler

17
orchestrator/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Job Ops Orchestrator - Manage your job applications intelligently" />
<title>Job Ops | Orchestrator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

5403
orchestrator/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
orchestrator/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "job-ops-orchestrator",
"version": "1.0.0",
"type": "module",
"description": "Unified orchestrator for job application pipeline",
"main": "dist/server/index.js",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite",
"build": "npm run build:client && npm run build:server",
"build:server": "tsc -p tsconfig.server.json",
"build:client": "vite build",
"start": "node dist/server/index.js",
"db:migrate": "tsx src/server/db/migrate.ts",
"pipeline:run": "tsx src/server/pipeline/run.ts"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2",
"express": "^4.18.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"drizzle-kit": "^0.30.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

View File

@ -0,0 +1,162 @@
/**
* Main App component.
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { Job, JobStatus } from '../shared/types';
import { Header, Stats, JobList, ToastContainer, Toast } from './components';
import * as api from './api';
export const App: React.FC = () => {
// State
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Record<JobStatus, number>>({
discovered: 0,
processing: 0,
ready: 0,
applied: 0,
rejected: 0,
expired: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const [toasts, setToasts] = useState<Toast[]>([]);
// Toast helpers
const addToast = useCallback((message: string, type: Toast['type']) => {
const id = Math.random().toString(36).slice(2);
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// Load jobs
const loadJobs = useCallback(async () => {
try {
setIsLoading(true);
const data = await api.getJobs();
setJobs(data.jobs);
setStats(data.byStatus);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load jobs';
addToast(message, 'error');
} finally {
setIsLoading(false);
}
}, [addToast]);
// Check pipeline status
const checkPipelineStatus = useCallback(async () => {
try {
const status = await api.getPipelineStatus();
setIsPipelineRunning(status.isRunning);
} catch {
// Ignore errors
}
}, []);
// Initial load
useEffect(() => {
loadJobs();
checkPipelineStatus();
// Poll for updates
const interval = setInterval(() => {
loadJobs();
checkPipelineStatus();
}, 10000);
return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]);
// Run pipeline
const handleRunPipeline = async () => {
try {
setIsPipelineRunning(true);
await api.runPipeline();
addToast('Pipeline started! This may take a few minutes.', 'info');
// Poll more frequently while running
const pollInterval = setInterval(async () => {
const status = await api.getPipelineStatus();
if (!status.isRunning) {
clearInterval(pollInterval);
setIsPipelineRunning(false);
loadJobs();
addToast('Pipeline completed!', 'success');
}
}, 5000);
} catch (error) {
setIsPipelineRunning(false);
const message = error instanceof Error ? error.message : 'Failed to start pipeline';
addToast(message, 'error');
}
};
// Process single job
const handleProcess = async (jobId: string) => {
try {
setProcessingJobId(jobId);
await api.processJob(jobId);
addToast('Resume generated successfully!', 'success');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to process job';
addToast(message, 'error');
} finally {
setProcessingJobId(null);
}
};
// Mark as applied
const handleApply = async (jobId: string) => {
try {
await api.markAsApplied(jobId);
addToast('Marked as applied! ✅', 'success');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to mark as applied';
addToast(message, 'error');
}
};
// Reject job
const handleReject = async (jobId: string) => {
try {
await api.rejectJob(jobId);
addToast('Job skipped', 'info');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to reject job';
addToast(message, 'error');
}
};
return (
<>
<Header
onRunPipeline={handleRunPipeline}
onRefresh={loadJobs}
isPipelineRunning={isPipelineRunning}
isLoading={isLoading}
/>
<main className="container" style={{ paddingBottom: 'var(--space-12)' }}>
<Stats stats={stats} />
<JobList
jobs={jobs}
onApply={handleApply}
onReject={handleReject}
onProcess={handleProcess}
processingJobId={processingJobId}
/>
</main>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</>
);
};

View File

@ -0,0 +1,91 @@
/**
* API client for the orchestrator backend.
*/
import type {
Job,
ApiResponse,
JobsListResponse,
PipelineStatusResponse,
PipelineRun
} from '../../shared/types';
const API_BASE = '/api';
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
const data: ApiResponse<T> = await response.json();
if (!data.success) {
throw new Error(data.error || 'API request failed');
}
return data.data as T;
}
// Jobs API
export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
const query = statuses?.length ? `?status=${statuses.join(',')}` : '';
return fetchApi<JobsListResponse>(`/jobs${query}`);
}
export async function getJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`);
}
export async function updateJob(
id: string,
update: Partial<Job>
): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`, {
method: 'PATCH',
body: JSON.stringify(update),
});
}
export async function processJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/process`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
});
}
export async function rejectJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/reject`, {
method: 'POST',
});
}
// Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>('/pipeline/status');
}
export async function getPipelineRuns(): Promise<PipelineRun[]> {
return fetchApi<PipelineRun[]>('/pipeline/runs');
}
export async function runPipeline(config?: {
topN?: number;
minSuitabilityScore?: number;
}): Promise<{ message: string }> {
return fetchApi<{ message: string }>('/pipeline/run', {
method: 'POST',
body: JSON.stringify(config || {}),
});
}

View File

@ -0,0 +1 @@
export * from './client';

View File

@ -0,0 +1,64 @@
/**
* Header component with logo and pipeline trigger.
*/
import React from 'react';
import { RocketIcon, PlayIcon, RefreshIcon } from './Icons';
interface HeaderProps {
onRunPipeline: () => void;
onRefresh: () => void;
isPipelineRunning: boolean;
isLoading: boolean;
}
export const Header: React.FC<HeaderProps> = ({
onRunPipeline,
onRefresh,
isPipelineRunning,
isLoading,
}) => {
return (
<header className="header">
<div className="container">
<div className="header-content">
<div className="logo">
<div className="logo-icon">
<RocketIcon size={20} />
</div>
<span className="logo-text">Job Ops</span>
</div>
<div className="flex gap-3">
<button
className="btn btn-ghost"
onClick={onRefresh}
disabled={isLoading}
>
<RefreshIcon size={16} />
Refresh
</button>
<button
className="btn btn-primary"
onClick={onRunPipeline}
disabled={isPipelineRunning}
>
{isPipelineRunning ? (
<>
<div className="spinner" />
Running...
</>
) : (
<>
<PlayIcon size={16} />
Run Pipeline
</>
)}
</button>
</div>
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,118 @@
/**
* SVG Icons as React components.
*/
import React from 'react';
interface IconProps {
className?: string;
size?: number;
}
export const BriefcaseIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
);
export const MapPinIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
export const CalendarIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
);
export const DollarIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
);
export const GraduationCapIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z"/>
<path d="M6 12v5c3 3 9 3 12 0v-5"/>
</svg>
);
export const ExternalLinkIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
);
export const FileTextIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
);
export const CheckCircleIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
);
export const XCircleIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
);
export const RefreshIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
);
export const PlayIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
);
export const DownloadIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
);
export const XIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
export const RocketIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
);

View File

@ -0,0 +1,174 @@
/**
* Individual job card component.
*/
import React from 'react';
import type { Job } from '../../shared/types';
import { StatusBadge } from './StatusBadge';
import { ScoreIndicator } from './ScoreIndicator';
import {
MapPinIcon,
CalendarIcon,
DollarIcon,
GraduationCapIcon,
ExternalLinkIcon,
DownloadIcon,
CheckCircleIcon,
XCircleIcon,
RefreshIcon,
} from './Icons';
interface JobCardProps {
job: Job;
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
isProcessing: boolean;
}
export const JobCard: React.FC<JobCardProps> = ({
job,
onApply,
onReject,
onProcess,
isProcessing,
}) => {
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
const hasPdf = !!job.pdfPath;
const canApply = job.status === 'ready';
const canProcess = job.status === 'discovered';
const canReject = ['discovered', 'ready'].includes(job.status);
return (
<article className="job-card">
<div className="job-card-header">
<div>
<h3 className="job-title">{job.title}</h3>
<p className="job-employer">{job.employer}</p>
</div>
<div className="flex items-center gap-3">
<ScoreIndicator score={job.suitabilityScore} />
<StatusBadge status={job.status} />
</div>
</div>
<div className="job-meta">
{job.location && (
<span className="job-meta-item">
<MapPinIcon />
{job.location}
</span>
)}
{job.deadline && (
<span className="job-meta-item">
<CalendarIcon />
{job.deadline}
</span>
)}
{job.salary && (
<span className="job-meta-item">
<DollarIcon />
{job.salary}
</span>
)}
{job.degreeRequired && (
<span className="job-meta-item">
<GraduationCapIcon />
{job.degreeRequired}
</span>
)}
</div>
{job.suitabilityReason && (
<p style={{
marginTop: 'var(--space-3)',
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
fontStyle: 'italic',
}}>
"{job.suitabilityReason}"
</p>
)}
<div className="job-actions">
{/* View job posting */}
<a
href={job.applicationLink || job.jobUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
<ExternalLinkIcon size={16} />
View Job
</a>
{/* Download PDF */}
{hasPdf && (
<a
href={`/pdfs/resume_${job.id}.pdf`}
download
className="btn btn-ghost"
>
<DownloadIcon size={16} />
Download PDF
</a>
)}
{/* Process job */}
{canProcess && (
<button
className="btn btn-ghost"
onClick={() => onProcess(job.id)}
disabled={isProcessing}
>
{isProcessing ? (
<>
<div className="spinner" />
Processing...
</>
) : (
<>
<RefreshIcon size={16} />
Generate Resume
</>
)}
</button>
)}
{/* Reject */}
{canReject && (
<button
className="btn btn-danger"
onClick={() => onReject(job.id)}
>
<XCircleIcon size={16} />
Skip
</button>
)}
{/* Mark as applied */}
{canApply && (
<button
className="btn btn-success"
onClick={() => onApply(job.id)}
>
<CheckCircleIcon size={16} />
Mark Applied
</button>
)}
</div>
</article>
);
};

View File

@ -0,0 +1,90 @@
/**
* Job list with filtering tabs.
*/
import React, { useState } from 'react';
import type { Job, JobStatus } from '../../shared/types';
import { JobCard } from './JobCard';
interface JobListProps {
jobs: Job[];
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
processingJobId: string | null;
}
type FilterTab = 'ready' | 'discovered' | 'applied' | 'all';
const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: 'ready', label: '✨ Ready to Apply', statuses: ['ready'] },
{ id: 'discovered', label: '🔍 Discovered', statuses: ['discovered', 'processing'] },
{ id: 'applied', label: '✅ Applied', statuses: ['applied'] },
{ id: 'all', label: '📋 All Jobs', statuses: [] },
];
export const JobList: React.FC<JobListProps> = ({
jobs,
onApply,
onReject,
onProcess,
processingJobId,
}) => {
const [activeTab, setActiveTab] = useState<FilterTab>('ready');
const filteredJobs = React.useMemo(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab || tab.statuses.length === 0) {
return jobs;
}
return jobs.filter(job => tab.statuses.includes(job.status));
}, [jobs, activeTab]);
return (
<div>
<div className="tabs">
{tabs.map(tab => {
const count = tab.statuses.length === 0
? jobs.length
: jobs.filter(j => tab.statuses.includes(j.status)).length;
return (
<button
key={tab.id}
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label} ({count})
</button>
);
})}
</div>
{filteredJobs.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📭</div>
<h3 className="empty-state-title">No jobs found</h3>
<p>
{activeTab === 'ready' && 'Run the pipeline to discover and process new jobs.'}
{activeTab === 'discovered' && 'All discovered jobs have been processed.'}
{activeTab === 'applied' && "You haven't applied to any jobs yet."}
{activeTab === 'all' && 'No jobs in the system yet. Run the pipeline to get started!'}
</p>
</div>
) : (
<div className="job-list">
{filteredJobs.map(job => (
<JobCard
key={job.id}
job={job}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === job.id}
/>
))}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,37 @@
/**
* Suitability score display component.
*/
import React from 'react';
interface ScoreIndicatorProps {
score: number | null;
}
export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
if (score === null) {
return (
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Not scored
</span>
);
}
const getScoreClass = () => {
if (score >= 70) return 'score-high';
if (score >= 40) return 'score-medium';
return 'score-low';
};
return (
<div className={`score ${getScoreClass()}`}>
<div className="score-bar">
<div
className="score-bar-fill"
style={{ width: `${score}%` }}
/>
</div>
<span>{score}</span>
</div>
);
};

View File

@ -0,0 +1,50 @@
/**
* Stats dashboard showing job counts by status.
*/
import React from 'react';
import type { JobStatus } from '../../shared/types';
interface StatsProps {
stats: Record<JobStatus, number>;
}
const statConfig: Array<{
key: JobStatus;
label: string;
emoji: string;
}> = [
{ key: 'discovered', label: 'Discovered', emoji: '🔍' },
{ key: 'processing', label: 'Processing', emoji: '⚙️' },
{ key: 'ready', label: 'Ready', emoji: '✨' },
{ key: 'applied', label: 'Applied', emoji: '✅' },
{ key: 'rejected', label: 'Rejected', emoji: '❌' },
{ key: 'expired', label: 'Expired', emoji: '⏰' },
];
export const Stats: React.FC<StatsProps> = ({ stats }) => {
const total = Object.values(stats).reduce((a, b) => a + b, 0);
return (
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
<div className="card-header">
<h2>Overview</h2>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
{total} total jobs
</span>
</div>
<div className="stats-grid">
{statConfig.map(({ key, label, emoji }) => (
<div key={key} className="stat-card">
<div className="stat-value">{stats[key] || 0}</div>
<div className="stat-label">
<span style={{ marginRight: '4px' }}>{emoji}</span>
{label}
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,28 @@
/**
* Status badge component.
*/
import React from 'react';
import type { JobStatus } from '../../shared/types';
interface StatusBadgeProps {
status: JobStatus;
}
const statusLabels: Record<JobStatus, string> = {
discovered: 'Discovered',
processing: 'Processing',
ready: 'Ready',
applied: 'Applied',
rejected: 'Rejected',
expired: 'Expired',
};
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
return (
<span className={`badge badge-${status}`}>
{status === 'processing' && <span className="pulse"></span>}
{statusLabels[status]}
</span>
);
};

View File

@ -0,0 +1,44 @@
/**
* Toast notification component.
*/
import React, { useEffect } from 'react';
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastContainerProps {
toasts: Toast[];
onDismiss: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
return (
<div className="toast-container">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
};
const ToastItem: React.FC<{ toast: Toast; onDismiss: (id: string) => void }> = ({
toast,
onDismiss,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(toast.id);
}, 5000);
return () => clearTimeout(timer);
}, [toast.id, onDismiss]);
return (
<div className={`toast toast-${toast.type}`}>
<span>{toast.message}</span>
</div>
);
};

View File

@ -0,0 +1,8 @@
export { Header } from './Header';
export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator';
export { JobCard } from './JobCard';
export { JobList } from './JobList';
export { ToastContainer, type Toast } from './Toast';
export * from './Icons';

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,680 @@
/* ===================================================================
Job Ops Orchestrator - Design System
A modern, dark-mode first design with glassmorphism and gradients
=================================================================== */
/* CSS Custom Properties (Design Tokens) */
:root {
/* Colors */
--color-background: #0a0a0f;
--color-surface: #12121a;
--color-surface-elevated: #1a1a25;
--color-surface-glass: rgba(26, 26, 37, 0.7);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.12);
--color-text-primary: #f5f5f7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
/* Accent colors */
--color-primary: #6366f1;
--color-primary-light: #818cf8;
--color-primary-dark: #4f46e5;
--color-primary-glow: rgba(99, 102, 241, 0.3);
--color-success: #10b981;
--color-success-light: #34d399;
--color-success-glow: rgba(16, 185, 129, 0.2);
--color-warning: #f59e0b;
--color-warning-light: #fbbf24;
--color-danger: #ef4444;
--color-danger-light: #f87171;
--color-danger-glow: rgba(239, 68, 68, 0.2);
--color-info: #3b82f6;
--color-info-light: #60a5fa;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--gradient-success: linear-gradient(135deg, #10b981 0%, #34d399 100%);
--gradient-mesh: radial-gradient(at 40% 20%, hsla(250, 80%, 60%, 0.1) 0px, transparent 50%),
radial-gradient(at 80% 0%, hsla(280, 80%, 50%, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 50%, hsla(220, 100%, 60%, 0.05) 0px, transparent 50%);
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', monospace;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px var(--color-primary-glow);
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Reset & Base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
background-color: var(--color-background);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
}
#root {
min-height: 100vh;
background-image: var(--gradient-mesh);
background-attachment: fixed;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.02em;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
a {
color: var(--color-primary-light);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-primary);
}
/* Utility Classes */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--space-6);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
/* Glass Card */
.card {
background: var(--color-surface-glass);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-6);
transition: all var(--transition-normal);
}
.card:hover {
border-color: var(--color-border-light);
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--gradient-primary);
color: white;
box-shadow: var(--shadow-sm), 0 0 20px var(--color-primary-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px var(--color-primary-glow);
}
.btn-success {
background: var(--gradient-success);
color: white;
}
.btn-success:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 20px var(--color-success-glow);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-light);
}
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-surface);
color: var(--color-text-primary);
border-color: var(--color-border-light);
}
.btn-icon {
padding: var(--space-2);
border-radius: var(--radius-md);
}
/* Status Badges */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-discovered {
background: rgba(59, 130, 246, 0.15);
color: var(--color-info-light);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.badge-processing {
background: rgba(245, 158, 11, 0.15);
color: var(--color-warning-light);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.badge-ready {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success-light);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.badge-applied {
background: rgba(99, 102, 241, 0.15);
color: var(--color-primary-light);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.badge-rejected {
background: rgba(239, 68, 68, 0.15);
color: var(--color-danger-light);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-expired {
background: rgba(113, 113, 122, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(113, 113, 122, 0.3);
}
/* Score indicator */
.score {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: 0.875rem;
}
.score-bar {
width: 60px;
height: 6px;
background: var(--color-surface);
border-radius: var(--radius-full);
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.score-high .score-bar-fill { background: var(--color-success); }
.score-medium .score-bar-fill { background: var(--color-warning); }
.score-low .score-bar-fill { background: var(--color-danger); }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-4);
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1);
}
/* Job List */
.job-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.job-card {
background: var(--color-surface-glass);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-5);
transition: all var(--transition-normal);
}
.job-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.job-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.job-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.job-employer {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.job-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.job-meta-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.job-meta-item svg {
width: 14px;
height: 14px;
opacity: 0.7;
}
.job-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-4);
}
/* Tabs */
.tabs {
display: flex;
gap: var(--space-1);
background: var(--color-surface);
padding: var(--space-1);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
}
.tab {
flex: 1;
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.tab:hover {
color: var(--color-text-secondary);
}
.tab.active {
background: var(--gradient-primary);
color: white;
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--space-12);
color: var(--color-text-muted);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-2);
}
/* Loading Spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Pulse animation for processing jobs */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
/* Header */
.header {
padding: var(--space-6) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-8);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logo-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-lg);
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
animation: fadeIn var(--transition-fast) forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: slideUp var(--transition-normal) forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
}
.modal-body {
padding: var(--space-6);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border);
background: var(--color-surface-elevated);
border-radius: 0 0 var(--radius-2xl) var(--radius-2xl);
}
/* Toast */
.toast-container {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.toast {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
box-shadow: var(--shadow-lg);
min-width: 280px;
animation: slideIn var(--transition-normal) forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast-success { border-left: 3px solid var(--color-success); }
.toast-error { border-left: 3px solid var(--color-danger); }
.toast-info { border-left: 3px solid var(--color-info); }
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0 var(--space-4);
}
h1 { font-size: 1.5rem; }
.header-content {
flex-direction: column;
gap: var(--space-4);
align-items: flex-start;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
.job-card-header {
flex-direction: column;
}
.job-actions {
flex-direction: column;
}
.job-actions .btn {
width: 100%;
}
}

View File

@ -0,0 +1 @@
export { apiRouter } from './routes.js';

View File

@ -0,0 +1,272 @@
/**
* API routes for the orchestrator.
*/
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import { runPipeline, processJob, getPipelineStatus } from '../pipeline/index.js';
import { createNotionEntry } from '../services/notion.js';
import type { JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
export const apiRouter = Router();
// ============================================================================
// Jobs API
// ============================================================================
/**
* GET /api/jobs - List all jobs
* Query params: status (comma-separated list of statuses to filter)
*/
apiRouter.get('/jobs', async (req: Request, res: Response) => {
try {
const statusFilter = req.query.status as string | undefined;
const statuses = statusFilter?.split(',').filter(Boolean) as JobStatus[] | undefined;
const jobs = await jobsRepo.getAllJobs(statuses);
const stats = await jobsRepo.getJobStats();
const response: ApiResponse<JobsListResponse> = {
success: true,
data: {
jobs,
total: jobs.length,
byStatus: stats,
},
};
res.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/jobs/:id - Get a single job
*/
apiRouter.get('/jobs/:id', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* PATCH /api/jobs/:id - Update a job
*/
const updateJobSchema = z.object({
status: z.enum(['discovered', 'processing', 'ready', 'applied', 'rejected', 'expired']).optional(),
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
tailoredSummary: z.string().optional(),
pdfPath: z.string().optional(),
});
apiRouter.patch('/jobs/:id', async (req: Request, res: Response) => {
try {
const input = updateJobSchema.parse(req.body);
const job = await jobsRepo.updateJob(req.params.id, input);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/process - Process a single job (generate summary + PDF)
*/
apiRouter.post('/jobs/:id/process', async (req: Request, res: Response) => {
try {
const result = await processJob(req.params.id);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
*/
apiRouter.post('/jobs/:id/apply', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
const appliedAt = new Date().toISOString();
// Sync to Notion
const notionResult = await createNotionEntry({
id: job.id,
title: job.title,
employer: job.employer,
applicationLink: job.applicationLink,
deadline: job.deadline,
salary: job.salary,
location: job.location,
pdfPath: job.pdfPath,
appliedAt,
});
// Update job status
const updatedJob = await jobsRepo.updateJob(job.id, {
status: 'applied',
appliedAt,
notionPageId: notionResult.pageId,
});
res.json({ success: true, data: updatedJob });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/reject - Mark a job as rejected
*/
apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.updateJob(req.params.id, { status: 'rejected' });
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
// ============================================================================
// Pipeline API
// ============================================================================
/**
* GET /api/pipeline/status - Get pipeline status
*/
apiRouter.get('/pipeline/status', async (req: Request, res: Response) => {
try {
const { isRunning } = getPipelineStatus();
const lastRun = await pipelineRepo.getLatestPipelineRun();
const response: ApiResponse<PipelineStatusResponse> = {
success: true,
data: {
isRunning,
lastRun,
nextScheduledRun: null, // Would come from n8n
},
};
res.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/pipeline/runs - Get recent pipeline runs
*/
apiRouter.get('/pipeline/runs', async (req: Request, res: Response) => {
try {
const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ success: true, data: runs });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/pipeline/run - Trigger the pipeline manually
*/
const runPipelineSchema = z.object({
topN: z.number().min(1).max(50).optional(),
minSuitabilityScore: z.number().min(0).max(100).optional(),
});
apiRouter.post('/pipeline/run', async (req: Request, res: Response) => {
try {
const config = runPipelineSchema.parse(req.body);
// Start pipeline in background
runPipeline(config).catch(console.error);
res.json({
success: true,
data: { message: 'Pipeline started' }
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
// ============================================================================
// Webhook for n8n
// ============================================================================
/**
* POST /api/webhook/trigger - Webhook endpoint for n8n to trigger the pipeline
*/
apiRouter.post('/webhook/trigger', async (req: Request, res: Response) => {
// Optional: Add authentication check
const authHeader = req.headers.authorization;
const expectedToken = process.env.WEBHOOK_SECRET;
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
try {
// Start pipeline in background
runPipeline().catch(console.error);
res.json({
success: true,
data: {
message: 'Pipeline triggered',
triggeredAt: new Date().toISOString(),
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});

View File

@ -0,0 +1,30 @@
/**
* Database connection and initialization.
*/
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, mkdirSync } from 'fs';
import * as schema from './schema.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, '../../../data/jobs.db');
// Ensure data directory exists
const dataDir = dirname(DB_PATH);
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(DB_PATH);
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
export { schema };
export function closeDb() {
sqlite.close();
}

View File

@ -0,0 +1,77 @@
/**
* Database migration script - creates tables if they don't exist.
*/
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, mkdirSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, '../../../data/jobs.db');
// Ensure data directory exists
const dataDir = dirname(DB_PATH);
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(DB_PATH);
const migrations = [
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
employer TEXT NOT NULL,
employer_url TEXT,
job_url TEXT NOT NULL UNIQUE,
application_link TEXT,
disciplines TEXT,
deadline TEXT,
salary TEXT,
location TEXT,
degree_required TEXT,
starting TEXT,
job_description TEXT,
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'rejected', 'expired')),
suitability_score REAL,
suitability_reason TEXT,
tailored_summary TEXT,
pdf_path TEXT,
notion_page_id TEXT,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT,
applied_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS pipeline_runs (
id TEXT PRIMARY KEY,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed')),
jobs_discovered INTEGER NOT NULL DEFAULT 0,
jobs_processed INTEGER NOT NULL DEFAULT 0,
error_message TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
];
console.log('🔧 Running database migrations...');
for (const migration of migrations) {
try {
sqlite.exec(migration);
console.log('✅ Migration applied');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
}
sqlite.close();
console.log('🎉 Database migrations complete!');

View File

@ -0,0 +1,58 @@
/**
* Database schema using Drizzle ORM with SQLite.
*/
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const jobs = sqliteTable('jobs', {
id: text('id').primaryKey(),
// From crawler
title: text('title').notNull(),
employer: text('employer').notNull(),
employerUrl: text('employer_url'),
jobUrl: text('job_url').notNull().unique(),
applicationLink: text('application_link'),
disciplines: text('disciplines'),
deadline: text('deadline'),
salary: text('salary'),
location: text('location'),
degreeRequired: text('degree_required'),
starting: text('starting'),
jobDescription: text('job_description'),
// Orchestrator enrichments
status: text('status', {
enum: ['discovered', 'processing', 'ready', 'applied', 'rejected', 'expired']
}).notNull().default('discovered'),
suitabilityScore: real('suitability_score'),
suitabilityReason: text('suitability_reason'),
tailoredSummary: text('tailored_summary'),
pdfPath: text('pdf_path'),
notionPageId: text('notion_page_id'),
// Timestamps
discoveredAt: text('discovered_at').notNull().default(sql`(datetime('now'))`),
processedAt: text('processed_at'),
appliedAt: text('applied_at'),
createdAt: text('created_at').notNull().default(sql`(datetime('now'))`),
updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`),
});
export const pipelineRuns = sqliteTable('pipeline_runs', {
id: text('id').primaryKey(),
startedAt: text('started_at').notNull().default(sql`(datetime('now'))`),
completedAt: text('completed_at'),
status: text('status', {
enum: ['running', 'completed', 'failed']
}).notNull().default('running'),
jobsDiscovered: integer('jobs_discovered').notNull().default(0),
jobsProcessed: integer('jobs_processed').notNull().default(0),
errorMessage: text('error_message'),
});
export type JobRow = typeof jobs.$inferSelect;
export type NewJobRow = typeof jobs.$inferInsert;
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;

View File

@ -0,0 +1,71 @@
/**
* Express server entry point.
*/
import express from 'express';
import cors from 'cors';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { config } from 'dotenv';
import { apiRouter } from './api/index.js';
// Load environment variables from orchestrator root
const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: join(__dirname, '../../.env') });
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
});
next();
});
// API routes
app.use('/api', apiRouter);
// Serve static files for generated PDFs
const pdfDir = join(__dirname, '../../data/pdfs');
app.use('/pdfs', express.static(pdfDir));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve client app in production
if (process.env.NODE_ENV === 'production') {
const clientDir = join(__dirname, '../../dist/client');
app.use(express.static(clientDir));
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(join(clientDir, 'index.html'));
});
}
// Start server
app.listen(PORT, () => {
console.log(`
🚀 Job Ops Orchestrator
Server running at: http://localhost:${PORT} ║
API: http://localhost:${PORT}/api ║
Health: http://localhost:${PORT}/health ║
PDFs: http://localhost:${PORT}/pdfs ║
`);
});

View File

@ -0,0 +1 @@
export * from './orchestrator.js';

View File

@ -0,0 +1,283 @@
/**
* Main pipeline logic - orchestrates the daily job processing flow.
*
* Flow:
* 1. Run crawler to discover new jobs
* 2. Score jobs for suitability
* 3. Pick top N jobs
* 4. Generate tailored summaries
* 5. Generate PDF resumes
* 6. Mark as "ready" for user review
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { runCrawler } from '../services/crawler.js';
import { scoreAndRankJobs } from '../services/scorer.js';
import { generateSummary } from '../services/summary.js';
import { generatePdf } from '../services/pdf.js';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import type { Job, PipelineConfig } from '../../shared/types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_PROFILE_PATH = join(__dirname, '../../../../resume-generator/base.json');
const DEFAULT_CONFIG: PipelineConfig = {
topN: 10,
minSuitabilityScore: 50,
sources: ['gradcracker'],
profilePath: DEFAULT_PROFILE_PATH,
outputDir: join(__dirname, '../../../data/pdfs'),
};
// Track if pipeline is currently running
let isPipelineRunning = false;
/**
* Run the full job discovery and processing pipeline.
*/
export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise<{
success: boolean;
jobsDiscovered: number;
jobsProcessed: number;
error?: string;
}> {
if (isPipelineRunning) {
return {
success: false,
jobsDiscovered: 0,
jobsProcessed: 0,
error: 'Pipeline is already running',
};
}
isPipelineRunning = true;
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
// Create pipeline run record
const pipelineRun = await pipelineRepo.createPipelineRun();
console.log('🚀 Starting job pipeline...');
console.log(` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore}`);
try {
// Step 1: Load profile
console.log('\n📋 Loading profile...');
const profile = await loadProfile(mergedConfig.profilePath);
// Step 2: Run crawler
console.log('\n🕷 Running crawler...');
const crawlerResult = await runCrawler();
if (!crawlerResult.success) {
throw new Error(`Crawler failed: ${crawlerResult.error}`);
}
// Step 3: Import discovered jobs
console.log('\n💾 Importing jobs to database...');
const { created, skipped } = await jobsRepo.bulkCreateJobs(crawlerResult.jobs);
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
jobsDiscovered: created,
});
// Step 4: Get unprocessed jobs and score them
console.log('\n🎯 Scoring jobs for suitability...');
const unprocessedJobs = await jobsRepo.getJobsForProcessing(50); // Get more than topN for ranking
const rankedJobs = await scoreAndRankJobs(unprocessedJobs, profile);
// Update scores in database
for (const job of rankedJobs) {
await jobsRepo.updateJob(job.id, {
suitabilityScore: job.suitabilityScore,
suitabilityReason: job.suitabilityReason,
});
}
// Step 5: Pick top N jobs above threshold
const topJobs = rankedJobs
.filter(j => j.suitabilityScore >= mergedConfig.minSuitabilityScore)
.slice(0, mergedConfig.topN);
console.log(`\n📊 Selected ${topJobs.length} top jobs for processing:`);
for (const job of topJobs) {
console.log(` - ${job.title} @ ${job.employer} (score: ${job.suitabilityScore})`);
}
// Step 6: Process each top job
let processed = 0;
for (const job of topJobs) {
console.log(`\n📝 Processing: ${job.title} @ ${job.employer}`);
try {
// Mark as processing
await jobsRepo.updateJob(job.id, { status: 'processing' });
// Generate tailored summary
console.log(' Generating summary...');
const summaryResult = await generateSummary(
job.jobDescription || '',
profile
);
if (!summaryResult.success) {
console.warn(` ⚠️ Summary generation failed: ${summaryResult.error}`);
continue;
}
// Update job with summary
await jobsRepo.updateJob(job.id, {
tailoredSummary: summaryResult.summary,
});
// Generate PDF
console.log(' Generating PDF...');
const pdfResult = await generatePdf(
job.id,
summaryResult.summary!,
mergedConfig.profilePath
);
if (!pdfResult.success) {
console.warn(` ⚠️ PDF generation failed: ${pdfResult.error}`);
// Still mark as ready even if PDF failed - user can regenerate
}
// Mark as ready
await jobsRepo.updateJob(job.id, {
status: 'ready',
pdfPath: pdfResult.pdfPath ?? null,
});
processed++;
console.log(` ✅ Ready for review!`);
} catch (error) {
console.error(` ❌ Failed to process job: ${error}`);
// Continue with next job
}
}
// Update pipeline run as completed
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: 'completed',
completedAt: new Date().toISOString(),
jobsProcessed: processed,
});
console.log('\n🎉 Pipeline completed!');
console.log(` Jobs discovered: ${created}`);
console.log(` Jobs processed: ${processed}`);
isPipelineRunning = false;
return {
success: true,
jobsDiscovered: created,
jobsProcessed: processed,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: 'failed',
completedAt: new Date().toISOString(),
errorMessage: message,
});
isPipelineRunning = false;
console.error('\n❌ Pipeline failed:', message);
return {
success: false,
jobsDiscovered: 0,
jobsProcessed: 0,
error: message,
};
}
}
/**
* Process a single job (for manual processing).
*/
export async function processJob(jobId: string): Promise<{
success: boolean;
error?: string;
}> {
console.log(`📝 Processing job ${jobId}...`);
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) {
return { success: false, error: 'Job not found' };
}
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
// Mark as processing
await jobsRepo.updateJob(job.id, { status: 'processing' });
// Generate summary if not already done
if (!job.tailoredSummary) {
console.log(' Generating summary...');
const summaryResult = await generateSummary(
job.jobDescription || '',
profile
);
if (summaryResult.success) {
await jobsRepo.updateJob(job.id, {
tailoredSummary: summaryResult.summary,
});
job.tailoredSummary = summaryResult.summary ?? null;
}
}
// Generate PDF
console.log(' Generating PDF...');
const pdfResult = await generatePdf(
job.id,
job.tailoredSummary || '',
DEFAULT_PROFILE_PATH
);
// Mark as ready
await jobsRepo.updateJob(job.id, {
status: 'ready',
pdfPath: pdfResult.pdfPath ?? null,
});
console.log(' ✅ Done!');
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
}
}
/**
* Check if pipeline is currently running.
*/
export function getPipelineStatus(): { isRunning: boolean } {
return { isRunning: isPipelineRunning };
}
/**
* Load the user profile from JSON file.
*/
async function loadProfile(profilePath: string): Promise<Record<string, unknown>> {
try {
const content = await readFile(profilePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
console.warn('Failed to load profile, using empty object');
return {};
}
}

View File

@ -0,0 +1,45 @@
/**
* Standalone script to run the pipeline.
* Can be triggered by n8n or cron.
*
* Usage: npm run pipeline:run
*/
import { config } from 'dotenv';
import { runPipeline } from './orchestrator.js';
import { closeDb } from '../db/index.js';
// Load environment variables
config();
async function main() {
console.log('='.repeat(60));
console.log('🚀 Job Pipeline Runner');
console.log(` Started at: ${new Date().toISOString()}`);
console.log('='.repeat(60));
const result = await runPipeline({
topN: parseInt(process.env.PIPELINE_TOP_N || '10'),
minSuitabilityScore: parseInt(process.env.PIPELINE_MIN_SCORE || '50'),
});
console.log('\n' + '='.repeat(60));
console.log('📊 Pipeline Results:');
console.log(` Success: ${result.success}`);
console.log(` Jobs Discovered: ${result.jobsDiscovered}`);
console.log(` Jobs Processed: ${result.jobsProcessed}`);
if (result.error) {
console.log(` Error: ${result.error}`);
}
console.log(` Completed at: ${new Date().toISOString()}`);
console.log('='.repeat(60));
closeDb();
process.exit(result.success ? 0 : 1);
}
main().catch((error) => {
console.error('Fatal error:', error);
closeDb();
process.exit(1);
});

View File

@ -0,0 +1,2 @@
export * from './jobs.js';
export * from './pipeline.js';

View File

@ -0,0 +1,190 @@
/**
* Job repository - data access layer for jobs.
*/
import { eq, desc, sql, and, inArray } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { db, schema } from '../db/index.js';
import type { Job, CreateJobInput, UpdateJobInput, JobStatus } from '../../shared/types.js';
const { jobs } = schema;
/**
* Get all jobs, optionally filtered by status.
*/
export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
const query = statuses && statuses.length > 0
? db.select().from(jobs).where(inArray(jobs.status, statuses)).orderBy(desc(jobs.discoveredAt))
: db.select().from(jobs).orderBy(desc(jobs.discoveredAt));
const rows = await query;
return rows.map(mapRowToJob);
}
/**
* Get a single job by ID.
*/
export async function getJobById(id: string): Promise<Job | null> {
const [row] = await db.select().from(jobs).where(eq(jobs.id, id));
return row ? mapRowToJob(row) : null;
}
/**
* Get a job by its URL (for deduplication).
*/
export async function getJobByUrl(jobUrl: string): Promise<Job | null> {
const [row] = await db.select().from(jobs).where(eq(jobs.jobUrl, jobUrl));
return row ? mapRowToJob(row) : null;
}
/**
* Create a new job (or return existing if URL matches).
*/
export async function createJob(input: CreateJobInput): Promise<Job> {
// Check for existing job with same URL
const existing = await getJobByUrl(input.jobUrl);
if (existing) {
return existing;
}
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(jobs).values({
id,
title: input.title,
employer: input.employer,
employerUrl: input.employerUrl ?? null,
jobUrl: input.jobUrl,
applicationLink: input.applicationLink ?? null,
disciplines: input.disciplines ?? null,
deadline: input.deadline ?? null,
salary: input.salary ?? null,
location: input.location ?? null,
degreeRequired: input.degreeRequired ?? null,
starting: input.starting ?? null,
jobDescription: input.jobDescription ?? null,
status: 'discovered',
discoveredAt: now,
createdAt: now,
updatedAt: now,
});
return (await getJobById(id))!;
}
/**
* Update a job.
*/
export async function updateJob(id: string, input: UpdateJobInput): Promise<Job | null> {
const now = new Date().toISOString();
await db.update(jobs)
.set({
...input,
updatedAt: now,
...(input.status === 'processing' ? { processedAt: now } : {}),
...(input.status === 'applied' && !input.appliedAt ? { appliedAt: now } : {}),
})
.where(eq(jobs.id, id));
return getJobById(id);
}
/**
* Bulk create jobs from crawler results.
*/
export async function bulkCreateJobs(inputs: CreateJobInput[]): Promise<{ created: number; skipped: number }> {
let created = 0;
let skipped = 0;
for (const input of inputs) {
const existing = await getJobByUrl(input.jobUrl);
if (existing) {
skipped++;
continue;
}
await createJob(input);
created++;
}
return { created, skipped };
}
/**
* Get job statistics by status.
*/
export async function getJobStats(): Promise<Record<JobStatus, number>> {
const result = await db
.select({
status: jobs.status,
count: sql<number>`count(*)`,
})
.from(jobs)
.groupBy(jobs.status);
const stats: Record<JobStatus, number> = {
discovered: 0,
processing: 0,
ready: 0,
applied: 0,
rejected: 0,
expired: 0,
};
for (const row of result) {
stats[row.status as JobStatus] = row.count;
}
return stats;
}
/**
* Get jobs ready for processing (discovered with description).
*/
export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
const rows = await db
.select()
.from(jobs)
.where(
and(
eq(jobs.status, 'discovered'),
sql`${jobs.jobDescription} IS NOT NULL`
)
)
.orderBy(desc(jobs.discoveredAt))
.limit(limit);
return rows.map(mapRowToJob);
}
// Helper to map database row to Job type
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
return {
id: row.id,
title: row.title,
employer: row.employer,
employerUrl: row.employerUrl,
jobUrl: row.jobUrl,
applicationLink: row.applicationLink,
disciplines: row.disciplines,
deadline: row.deadline,
salary: row.salary,
location: row.location,
degreeRequired: row.degreeRequired,
starting: row.starting,
jobDescription: row.jobDescription,
status: row.status as JobStatus,
suitabilityScore: row.suitabilityScore,
suitabilityReason: row.suitabilityReason,
tailoredSummary: row.tailoredSummary,
pdfPath: row.pdfPath,
notionPageId: row.notionPageId,
discoveredAt: row.discoveredAt,
processedAt: row.processedAt,
appliedAt: row.appliedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}

View File

@ -0,0 +1,94 @@
/**
* Pipeline run repository.
*/
import { eq, desc } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { db, schema } from '../db/index.js';
import type { PipelineRun } from '../../shared/types.js';
const { pipelineRuns } = schema;
/**
* Create a new pipeline run.
*/
export async function createPipelineRun(): Promise<PipelineRun> {
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(pipelineRuns).values({
id,
startedAt: now,
status: 'running',
});
return {
id,
startedAt: now,
completedAt: null,
status: 'running',
jobsDiscovered: 0,
jobsProcessed: 0,
errorMessage: null,
};
}
/**
* Update a pipeline run.
*/
export async function updatePipelineRun(
id: string,
update: Partial<{
completedAt: string;
status: 'running' | 'completed' | 'failed';
jobsDiscovered: number;
jobsProcessed: number;
errorMessage: string;
}>
): Promise<void> {
await db.update(pipelineRuns).set(update).where(eq(pipelineRuns.id, id));
}
/**
* Get the latest pipeline run.
*/
export async function getLatestPipelineRun(): Promise<PipelineRun | null> {
const [row] = await db
.select()
.from(pipelineRuns)
.orderBy(desc(pipelineRuns.startedAt))
.limit(1);
if (!row) return null;
return {
id: row.id,
startedAt: row.startedAt,
completedAt: row.completedAt,
status: row.status as PipelineRun['status'],
jobsDiscovered: row.jobsDiscovered,
jobsProcessed: row.jobsProcessed,
errorMessage: row.errorMessage,
};
}
/**
* Get recent pipeline runs.
*/
export async function getRecentPipelineRuns(limit: number = 10): Promise<PipelineRun[]> {
const rows = await db
.select()
.from(pipelineRuns)
.orderBy(desc(pipelineRuns.startedAt))
.limit(limit);
return rows.map(row => ({
id: row.id,
startedAt: row.startedAt,
completedAt: row.completedAt,
status: row.status as PipelineRun['status'],
jobsDiscovered: row.jobsDiscovered,
jobsProcessed: row.jobsProcessed,
errorMessage: row.errorMessage,
}));
}

View File

@ -0,0 +1,112 @@
/**
* Service for running the job crawler (job-extractor).
* Wraps the existing Crawlee-based crawler.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readdir, readFile } from 'fs/promises';
import type { CreateJobInput } from '../../shared/types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRAWLER_DIR = join(__dirname, '../../../../job-extractor');
const STORAGE_DIR = join(CRAWLER_DIR, 'storage/datasets/default');
export interface CrawlerResult {
success: boolean;
jobs: CreateJobInput[];
error?: string;
}
/**
* Run the job-extractor crawler and return discovered jobs.
*/
export async function runCrawler(): Promise<CrawlerResult> {
console.log('🕷️ Starting job crawler...');
try {
// Clear previous results
await clearStorageDataset();
// Run the crawler
await new Promise<void>((resolve, reject) => {
const child = spawn('npm', ['run', 'start'], {
cwd: CRAWLER_DIR,
shell: true,
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Crawler exited with code ${code}`));
}
});
child.on('error', reject);
});
// Read crawled jobs from storage
const jobs = await readCrawledJobs();
console.log(`✅ Crawler completed. Found ${jobs.length} jobs.`);
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('❌ Crawler failed:', message);
return { success: false, jobs: [], error: message };
}
}
/**
* Read crawled jobs from the Crawlee storage dataset.
*/
async function readCrawledJobs(): Promise<CreateJobInput[]> {
try {
const files = await readdir(STORAGE_DIR);
const jsonFiles = files.filter(f => f.endsWith('.json'));
const jobs: CreateJobInput[] = [];
for (const file of jsonFiles) {
const content = await readFile(join(STORAGE_DIR, file), 'utf-8');
const data = JSON.parse(content);
// Map crawler output to our job input format
jobs.push({
title: data.title || 'Unknown Title',
employer: data.employer || 'Unknown Employer',
employerUrl: data.employerUrl,
jobUrl: data.url || data.jobUrl,
applicationLink: data.applicationLink,
disciplines: data.disciplines,
deadline: data.deadline,
salary: data.salary,
location: data.location,
degreeRequired: data.degreeRequired,
starting: data.starting,
jobDescription: data.jobDescription,
});
}
return jobs;
} catch (error) {
console.error('Failed to read crawled jobs:', error);
return [];
}
}
/**
* Clear previous crawl results.
*/
async function clearStorageDataset(): Promise<void> {
const { rm } = await import('fs/promises');
try {
await rm(STORAGE_DIR, { recursive: true, force: true });
} catch {
// Ignore if directory doesn't exist
}
}

View File

@ -0,0 +1,5 @@
export * from './crawler.js';
export * from './scorer.js';
export * from './summary.js';
export * from './pdf.js';
export * from './notion.js';

View File

@ -0,0 +1,89 @@
/**
* Service for syncing with Notion.
*/
export interface NotionSyncResult {
success: boolean;
pageId?: string;
error?: string;
}
/**
* Create a job entry in Notion.
*
* This is a placeholder - implement based on your Notion setup.
*/
export async function createNotionEntry(job: {
id: string;
title: string;
employer: string;
applicationLink: string | null;
deadline: string | null;
salary: string | null;
location: string | null;
pdfPath: string | null;
appliedAt: string;
}): Promise<NotionSyncResult> {
const notionApiKey = process.env.NOTION_API_KEY;
const notionDatabaseId = process.env.NOTION_DATABASE_ID;
if (!notionApiKey || !notionDatabaseId) {
console.log(' Notion API not configured, skipping sync');
return { success: true, pageId: undefined };
}
try {
const response = await fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${notionApiKey}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({
parent: { database_id: notionDatabaseId },
properties: {
// Customize these based on your Notion database schema
'Name': {
title: [{ text: { content: `${job.title} @ ${job.employer}` } }],
},
'Company': {
rich_text: [{ text: { content: job.employer } }],
},
'Application Link': job.applicationLink ? {
url: job.applicationLink,
} : undefined,
'Deadline': job.deadline ? {
date: { start: job.deadline },
} : undefined,
'Salary': job.salary ? {
rich_text: [{ text: { content: job.salary } }],
} : undefined,
'Location': job.location ? {
rich_text: [{ text: { content: job.location } }],
} : undefined,
'Applied Date': {
date: { start: job.appliedAt },
},
'Status': {
status: { name: 'Applied' },
},
},
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Notion API error: ${response.status} - ${error}`);
}
const data = await response.json();
console.log(`✅ Created Notion entry: ${data.id}`);
return { success: true, pageId: data.id };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ Notion sync failed: ${message}`);
return { success: false, error: message };
}
}

View File

@ -0,0 +1,144 @@
/**
* Service for generating PDF resumes using RXResume.
* Wraps the existing Python rxresume_automation.py script.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readFile, writeFile, copyFile, access, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESUME_GEN_DIR = join(__dirname, '../../../../resume-generator');
const OUTPUT_DIR = join(__dirname, '../../../data/pdfs');
export interface PdfResult {
success: boolean;
pdfPath?: string;
error?: string;
}
/**
* Generate a tailored PDF resume for a job.
*
* @param jobId - Unique job identifier (used for filename)
* @param tailoredSummary - The AI-generated summary to inject
* @param baseResumePath - Path to the base resume JSON (optional)
*/
export async function generatePdf(
jobId: string,
tailoredSummary: string,
baseResumePath?: string
): 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)) {
await mkdir(OUTPUT_DIR, { recursive: true });
}
// Read base resume
const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8'));
// Inject tailored summary
if (baseResume.sections?.summary) {
baseResume.sections.summary.content = tailoredSummary;
} else if (baseResume.basics?.summary) {
baseResume.basics.summary = tailoredSummary;
}
// Write modified resume to temp file
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
// Generate PDF using Python script
const outputFilename = `resume_${jobId}.pdf`;
const outputPath = join(OUTPUT_DIR, outputFilename);
await runPythonPdfGenerator(tempResumePath, outputFilename);
// Move generated PDF to our output directory
const pythonOutputPath = join(RESUME_GEN_DIR, 'resumes', outputFilename);
try {
await access(pythonOutputPath);
await copyFile(pythonOutputPath, outputPath);
} catch {
// PDF might already be in the right place or script output different location
console.warn('PDF not found at expected Python output location');
}
// Cleanup temp file
try {
const { unlink } = await import('fs/promises');
await unlink(tempResumePath);
} catch {
// Ignore cleanup errors
}
console.log(`✅ PDF generated: ${outputPath}`);
return { success: true, pdfPath: outputPath };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ PDF generation failed: ${message}`);
return { success: false, error: message };
}
}
/**
* Run the Python RXResume automation script.
*/
async function runPythonPdfGenerator(
jsonPath: string,
outputFilename: string
): Promise<void> {
return new Promise((resolve, reject) => {
// Note: This calls the Python script with the JSON path
// The Python script needs to be modified to accept these args
// For now, we'll use environment variables
const child = spawn('python3', ['rxresume_automation.py'], {
cwd: RESUME_GEN_DIR,
env: {
...process.env,
RESUME_JSON_PATH: jsonPath,
OUTPUT_FILENAME: outputFilename,
},
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python script exited with code ${code}`));
}
});
child.on('error', reject);
});
}
/**
* Check if a PDF exists for a job.
*/
export async function pdfExists(jobId: string): Promise<boolean> {
const pdfPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
try {
await access(pdfPath);
return true;
} catch {
return false;
}
}
/**
* Get the path to a job's PDF.
*/
export function getPdfPath(jobId: string): string {
return join(OUTPUT_DIR, `resume_${jobId}.pdf`);
}

View File

@ -0,0 +1,143 @@
/**
* Service for scoring job suitability using AI.
*/
import type { Job } from '../../shared/types.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
interface SuitabilityResult {
score: number; // 0-100
reason: string; // Explanation
}
/**
* Score a job's suitability based on profile and job description.
*/
export async function scoreJobSuitability(
job: Job,
profile: Record<string, unknown>
): Promise<SuitabilityResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
return mockScore(job);
}
const model = process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildScoringPrompt(job, profile);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content in response');
}
const parsed = JSON.parse(content);
return {
score: Math.min(100, Math.max(0, parsed.score || 0)),
reason: parsed.reason || 'No explanation provided',
};
} catch (error) {
console.error('Failed to score job:', error);
return mockScore(job);
}
}
function buildScoringPrompt(job: Job, profile: Record<string, unknown>): string {
return `
You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
Consider:
- Skills match (technologies, frameworks, languages)
- Experience level match
- Location/remote work alignment
- Industry/domain fit
- Career growth potential
Candidate Profile:
${JSON.stringify(profile, null, 2)}
Job Listing:
Title: ${job.title}
Employer: ${job.employer}
Location: ${job.location || 'Not specified'}
Salary: ${job.salary || 'Not specified'}
Degree Required: ${job.degreeRequired || 'Not specified'}
Disciplines: ${job.disciplines || 'Not specified'}
Job Description:
${job.jobDescription || 'No description available'}
Respond with JSON: { "score": <0-100>, "reason": "<brief explanation>" }
`;
}
function mockScore(job: Job): SuitabilityResult {
// Simple keyword-based scoring as fallback
const jd = (job.jobDescription || '').toLowerCase();
const title = job.title.toLowerCase();
const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer'];
const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager'];
let score = 50;
for (const kw of goodKeywords) {
if (jd.includes(kw) || title.includes(kw)) score += 5;
}
for (const kw of badKeywords) {
if (jd.includes(kw) || title.includes(kw)) score -= 10;
}
score = Math.min(100, Math.max(0, score));
return {
score,
reason: 'Scored using keyword matching (API key not configured)',
};
}
/**
* Score multiple jobs and return sorted by score (descending).
*/
export async function scoreAndRankJobs(
jobs: Job[],
profile: Record<string, unknown>
): Promise<Array<Job & { suitabilityScore: number; suitabilityReason: string }>> {
const scoredJobs = await Promise.all(
jobs.map(async (job) => {
const { score, reason } = await scoreJobSuitability(job, profile);
return {
...job,
suitabilityScore: score,
suitabilityReason: reason,
};
})
);
return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
}

View File

@ -0,0 +1,153 @@
/**
* Service for generating tailored resume summaries.
* Wraps the existing Python generate_summary.py script.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { writeFile, unlink } from 'fs/promises';
import { randomUUID } from 'crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESUME_GEN_DIR = join(__dirname, '../../../../resume-generator');
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface SummaryResult {
success: boolean;
summary?: string;
error?: string;
}
/**
* Generate a tailored resume summary for a job.
* Uses the native implementation instead of calling Python.
*/
export async function generateSummary(
jobDescription: string,
profile: Record<string, unknown>
): Promise<SummaryResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate summary');
return { success: false, error: 'API key not configured' };
}
const model = process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildSummaryPrompt(profile, jobDescription);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'http://localhost',
'X-Title': 'JobOpsOrchestrator',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!response.ok) {
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const summary = data.choices[0]?.message?.content;
if (!summary) {
throw new Error('No content in response');
}
return { success: true, summary: summary.trim() };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
}
}
function buildSummaryPrompt(profile: Record<string, unknown>, jd: string): string {
return `
You are generating a tailored résumé summary for me.
Requirements:
- Use keywords found in the job description.
- Keep it concise but meaningful. Avoid fluff. Avoid long-winded text.
- Include just enough detail to feel real and grounded.
- Gently convey that I care about helping people and doing good work.
- Do NOT invent experience or skills I don't have.
- Maintain a warm, confident, human tone.
- Target THIS specific job directly, so use ATS keywords, while remaining natural.
- Use the profile to add context and details.
My profile (JSON fields merged):
${JSON.stringify(profile, null, 2)}
Job description:
${jd}
Write the résumé summary now.
`;
}
/**
* Alternative: Call the Python script directly.
* Useful if the Python script has additional functionality.
*/
export async function generateSummaryViaPython(
jobDescription: string
): Promise<SummaryResult> {
const tempFile = join(RESUME_GEN_DIR, `temp_jd_${randomUUID()}.txt`);
try {
// Write JD to temp file
await writeFile(tempFile, jobDescription);
// Call Python script
const result = await new Promise<string>((resolve, reject) => {
let output = '';
let error = '';
const child = spawn('python3', ['generate_summary.py', '--file', tempFile], {
cwd: RESUME_GEN_DIR,
env: { ...process.env },
});
child.stdout.on('data', (data) => {
output += data.toString();
});
child.stderr.on('data', (data) => {
error += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(error || `Process exited with code ${code}`));
}
});
child.on('error', reject);
});
return { success: true, summary: result.trim() };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: message };
} finally {
// Cleanup temp file
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}

View File

@ -0,0 +1 @@
export * from './types';

View File

@ -0,0 +1,106 @@
/**
* Shared types for the job-ops orchestrator.
*/
export type JobStatus =
| 'discovered' // Crawled but not processed
| 'processing' // Currently generating resume
| 'ready' // PDF generated, waiting for user to apply
| 'applied' // User marked as applied (added to Notion)
| 'rejected' // User rejected this job
| 'expired'; // Deadline passed
export interface Job {
id: string;
// From crawler
title: string;
employer: string;
employerUrl: string | null;
jobUrl: string; // Gradcracker listing URL
applicationLink: string | null; // Actual application URL
disciplines: string | null;
deadline: string | null;
salary: string | null;
location: string | null;
degreeRequired: string | null;
starting: string | null;
jobDescription: string | null;
// Orchestrator enrichments
status: JobStatus;
suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation
tailoredSummary: string | null; // Generated resume summary
pdfPath: string | null; // Path to generated PDF
notionPageId: string | null; // Notion page ID if synced
// Timestamps
discoveredAt: string;
processedAt: string | null;
appliedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateJobInput {
title: string;
employer: string;
employerUrl?: string;
jobUrl: string;
applicationLink?: string;
disciplines?: string;
deadline?: string;
salary?: string;
location?: string;
degreeRequired?: string;
starting?: string;
jobDescription?: string;
}
export interface UpdateJobInput {
status?: JobStatus;
suitabilityScore?: number;
suitabilityReason?: string;
tailoredSummary?: string;
pdfPath?: string;
notionPageId?: string;
appliedAt?: string;
}
export interface PipelineConfig {
topN: number; // Number of top jobs to process
minSuitabilityScore: number; // Minimum score to auto-process
sources: string[]; // Job sources to crawl
profilePath: string; // Path to profile JSON
outputDir: string; // Directory for generated PDFs
}
export interface PipelineRun {
id: string;
startedAt: string;
completedAt: string | null;
status: 'running' | 'completed' | 'failed';
jobsDiscovered: number;
jobsProcessed: number;
errorMessage: string | null;
}
// API Response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
export interface JobsListResponse {
jobs: Job[];
total: number;
byStatus: Record<JobStatus, number>;
}
export interface PipelineStatusResponse {
isRunning: boolean;
lastRun: PipelineRun | null;
nextScheduledRun: string | null;
}

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@server/*": ["src/server/*"],
"@client/*": ["src/client/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist/server",
"rootDir": "./src/server"
},
"include": ["src/server/**/*", "src/shared/**/*"]
}

View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@client': path.resolve(__dirname, './src/client'),
'@shared': path.resolve(__dirname, './src/shared'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist/client',
emptyOutDir: true,
},
});

View File

@ -7,8 +7,8 @@ from pathlib import Path
from playwright.sync_api import sync_playwright
# Configuration
RXRESUME_EMAIL = os.getenv("RXRESUME_EMAIL", "ssarfaraz@lancashire.ac.uk")
RXRESUME_PASSWORD = os.getenv("RXRESUME_PASSWORD", "thisisatestpassword")
RXRESUME_EMAIL = os.getenv("RXRESUME_EMAIL", "")
RXRESUME_PASSWORD = os.getenv("RXRESUME_PASSWORD", "")
BASE_DIR = Path(__file__).parent
RESUME_JSON_PATH = BASE_DIR / "base.json"
@ -79,7 +79,7 @@ def generate_resume_pdf(
output_path = OUTPUT_DIR / output_filename
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=False)
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()

View File

@ -0,0 +1,661 @@
{
"basics": {
"name": "Shaheer Sarfaraz",
"headline": "Frontend Software Engineer (React/TypeScript) · Autodesk Intern · 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": ""
},
"awards": {
"name": "Awards",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "awards",
"items": []
},
"certifications": {
"name": "Certifications",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "certifications",
"items": []
},
"education": {
"name": "Education",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "education",
"items": [
{
"id": "yo3p200zo45c6cdqc6a2vtt3",
"visible": true,
"institution": "University of Lancashire",
"studyType": "BSc (Hons) Computer Science",
"area": "Preston, United Kingdom",
"score": "1st Class",
"date": "September 2022 to June 2026",
"summary": "<p style=\"text-align: left;\">Relevant Modules: Web Applications, Algorithms &amp; Data Structures, Game Development, Databases, Software Engineering (Agile group project)</p>",
"url": {
"label": "",
"href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc"
}
},
{
"id": "ei2fvjokusg3cfmdyolmgcoz",
"visible": false,
"institution": " ",
"studyType": "",
"area": "A Levels",
"score": "",
"date": "",
"summary": "<ul><li><p>Maths: A</p></li><li><p>Computer Science: B</p></li><li><p>Physics: C</p></li><li><p>Chemistry: E</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "pm4r5hngvv1w4mc79o22irfx",
"visible": false,
"institution": " ",
"studyType": "",
"area": "GCSEs",
"score": "",
"date": "",
"summary": "<ol><li><p>English: A*</p></li><li><p>Computer Science: A*</p></li><li><p>Urdu: A</p></li><li><p>Islamiat: A</p></li><li><p>Pakistan Studies: A</p></li><li><p>Biology: A</p></li><li><p>Chemistry: A</p></li><li><p>Physics: A</p></li><li><p>Maths: A</p></li></ol>",
"url": {
"label": "",
"href": ""
}
}
]
},
"experience": {
"name": "Experience",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "experience",
"items": [
{
"id": "ng9ui2azk7w4y8oyu8kazqeb",
"visible": true,
"company": "Autodesk",
"position": "Software Engineering Intern",
"location": "Hybrid (Sheffield Based)",
"date": "July 2024 - June 2025",
"summary": "<ul><li><p><strong>Implemented front-end features and fixes</strong> in the Autodesk Construction Cloud Model Coordination app, working in a ~10-year-old React/JavaScript/TypeScript codebase (7k+ commits) using Webpack module federation and Autodesks 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 “test fest” events ahead of major feature releases</p></li><li><p>Collaborated with cross-functional teams (like the Design System, platform teams) by <strong>raising well-scoped bugs</strong>, augmenting existing tickets with reproduction steps and context, and aligning on shared component and API changes</p></li><li><p>Helped strengthen team processes by <strong>running weekly stand-ups</strong> and retrospectives, organising a ticket-scoping meeting, and <strong>participating in technical reviews &amp; ADR discussions</strong> (e.g. standardising error handling and planning clash data streaming)</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "lhw25d7gf32wgdfpsktf6e0x",
"visible": true,
"company": "Mirage",
"position": "Co-Founder & Lead Developer",
"location": "",
"date": "December 2019 to Present",
"summary": "<ul><li><p>Delivered <strong>10+ production websites and webapps</strong> for small and medium size clients (e.g. Indus Marine Services, Mumtaz Urdu), from initial scoping to deployment and handover</p></li><li><p>Built with <strong>modern web stacks</strong> (Next.js, Node/Express, Tailwind, Strapi, WordPress/Elementor where appropriate), setting up CI/CD and hosting</p></li><li><p><strong>Led a small team of four developers</strong>, handling code reviews, task breakdown, and client communication</p></li></ul>",
"url": {
"label": "",
"href": "https://promirage.com/"
}
},
{
"id": "k6zxqunkb225hbjso3c3vykk",
"visible": true,
"company": "University of Lancashire",
"position": "Computing Student Mentor",
"location": "Preston, UK",
"date": "July 2023 - July 2024",
"summary": "<ul><li><p><strong>Academic Support and Leadership:</strong> Provided academic guidance to over 10 first-year students once a week, significantly enhancing their understanding and skills in key subjects like programming and web development.</p></li><li><p style=\"text-align: start\"><strong>Collaborative Learning Environment:</strong> Actively fostered a collaborative and supportive learning environment for a group of 10 students. This role also honed my leadership and communication skills, facilitating better academic outcomes for mentees.</p></li></ul>",
"url": {
"label": "",
"href": ""
}
},
{
"id": "a1bg5d8gp8sulf91xzdcsiaq",
"visible": true,
"company": "Research and Knowledge Exchange Institute",
"position": "Undergraduate Research Intern (HCI & EdTech)",
"location": "",
"date": "Summer 2024",
"summary": "<ul><li><p>Built a <strong>mouse “torch-reveal” web app</strong> (<strong>Astro</strong>) to approximate eye-tracking; ran on-campus studies with Revoe Learning Academy pupils—<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 universitys 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 Students Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.</p></li><li><p><strong>User Authentication and Session Management:</strong> Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.</p></li><li><p><strong>Dynamic Content Display from Database:</strong> Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.</p></li><li><p><strong>Advanced Search and Personalization Features:</strong> Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "ov4lkbc1vl169ynfnj91m1lm",
"visible": false,
"name": "Square About",
"description": "",
"date": "UCLan Year 1",
"summary": "<ul><li><p><strong>Advanced 3D Game Development:</strong> Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.</p></li><li><p><strong>Dynamic Gameplay Elements:</strong> Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.</p></li><li><p><strong>Interactive Game Controls:</strong> Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.</p></li><li><p><strong>Strategic Game Mechanics:</strong> Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "s3r37gdr0oa84a6dp6r5nl58",
"visible": false,
"name": "Car Smash",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>3D Car Smash Game Development:</strong> Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.</p></li><li><p><strong>Collision Detection Mechanics:</strong> Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.</p></li><li><p><strong>Dynamic Game States and Camera Views:</strong> Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.</p></li><li><p><strong>Enhanced Player Interaction:</strong> Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "gylzkvl103m9s7ywag4xpdy4",
"visible": false,
"name": "Tweet Filter",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>Tweet Filtration System:</strong> Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.</p></li><li><p><strong>Advanced Text Manipulation:</strong> Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.</p></li><li><p><strong>Output Generation:</strong> Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.</p></li><li><p><strong>Algorithm Optimization:</strong> Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "enav754zxhuc9uycbb83s94q",
"visible": false,
"name": "Burger Ordering App",
"description": "",
"date": "UCLan Year 1",
"summary": "<ol><li><p><strong>Interactive Console Application:</strong> Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.</p></li><li><p><strong>Complex Logic Implementation:</strong> Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.</p></li><li><p><strong>Data Handling and User Input:</strong> Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.</p></li><li><p><strong>Readable and Maintainable Code:</strong> Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "hl6jgeswr01tlul3iwoat05d",
"visible": false,
"name": "LinkLander",
"description": "Android Studio, Kotlin",
"date": "December 2023 - Ongoing",
"summary": "<ul><li><p><strong>Innovative Android Utility:</strong> Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.</p></li><li><p><strong>User-Centric Design:</strong> Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.</p></li><li><p><strong>Simplicity and Efficiency:</strong> Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.</p></li><li><p><strong>Technical Proficiency in Kotlin:</strong> Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "v4s0ljbiiio198y8l1wl0ym6",
"visible": false,
"name": "AR App Development with AGILE",
"description": "Unity, C#",
"date": "October 2023 - Ongoing",
"summary": "<ul><li><p><strong>Agile Development in Action</strong>: Participated in an Agile team project, developing an AR application for supporting disabled students with a team of five, demonstrating an application of Agile methodologies in a real-world scenario.</p></li><li><p><strong>Mobile AR Application Prototype</strong>: Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.</p></li><li><p><strong>Collaborative Software Engineering</strong>: Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.</p></li><li><p><strong>Presentation and Critical Analysis</strong>: Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.</p></li></ul>",
"keywords": [],
"url": {
"label": "",
"href": ""
}
},
{
"id": "fwxrq682hqrj1y76rmziqrbk",
"visible": true,
"name": "Indus Marine Services",
"description": "System Design & Development",
"date": "May 2022 - Ongoing",
"summary": "<ol><li><p><strong>Induction System for Marine Services</strong>: Designed &amp; developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.</p></li><li><p><strong>Admin-Centric Functionality</strong>: Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements</p></li><li><p><strong>Client Engagement Interface</strong>: Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.</p></li><li><p><strong>Robust Tech Stack Integration</strong>: Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "http://www.ims-auh.com"
}
},
{
"id": "jdfyaez8vq1b7xfr9rmxmz06",
"visible": false,
"name": "VECTOR AI",
"description": "Website Development",
"date": "February 2024 - February 2024",
"summary": "<ol><li><p><strong>Innovative AI Development</strong>: As the driving force behind VECTOR's website development, I spearheaded the technical design using Astro, with a cutting-edge stack including React and Tailwind CSS.</p></li><li><p><strong>Data-Driven Content Strategy</strong>: Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.</p></li><li><p><strong>Astro for Enhanced Performance</strong>: Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience</p></li><li><p><strong>React for Responsive Interaction</strong>: Utilized Reacts robust ecosystem to develop interactive elements, ensuring that each module of VECTORs 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 45, 2025",
"summary": "<ol><li><p><strong>Full-Stack Integration:</strong> Wired up backend services to a responsive frontend, enabling real-time exploration of <strong>Kepler/K2/TESS</strong> catalogs and smooth model-scoring UX.</p></li><li><p><strong>Data Harmonization Pipeline:</strong> Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by <strong>60%+</strong> during the hack.</p></li><li><p><strong>Analytics UI &amp; Upload Flow:</strong> Built an upload → validate → 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> — external contributors opened feature requests and bug reports, validating real-world adoption and reliability.</p></li><li><p><strong>Continuous Personal Use &amp; Maintenance:</strong> Regularly updated and used in live deployment at <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"http://lifting.dakheera47.com\">lifting.dakheera47.com</a>, tracking <strong>hundreds of sets</strong> over time with persistent analytics and performance trends.</p></li></ol>",
"keywords": [],
"url": {
"label": "",
"href": "https://lifting.dakheera47.com/"
}
}
]
},
"publications": {
"name": "Publications",
"columns": 1,
"separateLinks": true,
"visible": true,
"id": "publications",
"items": []
},
"references": {
"name": "References",
"columns": 1,
"separateLinks": true,
"visible": false,
"id": "references",
"items": [
{
"id": "f2sv5z0cce6ztjl87yuk8fak",
"visible": true,
"name": "Available upon request",
"description": "",
"summary": "",
"url": {
"label": "",
"href": ""
}
}
]
},
"skills": {
"name": "Skills",
"columns": 2,
"separateLinks": true,
"visible": true,
"id": "skills",
"items": [
{
"id": "jfgzfcwcg65k9gemuxlfe9m3",
"visible": true,
"name": "Frontend Development",
"description": "",
"level": 0,
"keywords": [
"React",
"Next.js",
"Tailwind CSS",
"Strapi CMS",
"Elementor",
"GraphQL",
"TypeScript",
"CI/CD",
"PWA Development",
"AstroJS",
"React Testing Library"
]
},
{
"id": "sk3957foopxir2hw4xzxqahh",
"visible": true,
"name": "Backend Development",
"description": "",
"level": 0,
"keywords": [
"Node.js",
"Express.js",
"MongoDB",
"Supabase",
"Firebase",
"Docker",
"FastAPI",
"AWS S3",
"AWS SES"
]
},
{
"id": "d9bddwdj6qreknhk644rm0bs",
"visible": true,
"name": "Leadership and Problem-Solving",
"description": "",
"level": 0,
"keywords": [
"Agile Project Management",
"Conflict Resolution",
"Creative Problem-Solving",
"Decision-Making",
"Effective Communication",
"Adaptability"
]
},
{
"id": "gk4hrky0wnbsbdcmmud48zjh",
"visible": true,
"name": "Other Programming",
"description": "",
"level": 0,
"keywords": [
"Python Scripting",
"PyAutoGUI",
"Git",
"GitHub",
"Selenium",
"Data Analysis",
"Web Scraping",
"Data Cleaning"
]
}
]
},
"custom": {}
},
"metadata": {
"template": "onyx",
"layout": [
[
[
"summary",
"education",
"experience",
"projects",
"references"
],
[
"profiles",
"skills",
"certifications",
"interests",
"languages",
"awards",
"volunteer",
"publications"
]
]
],
"css": {
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
"visible": false
},
"page": {
"margin": 34,
"format": "a4",
"options": {
"breakLine": false,
"pageNumbers": false
}
},
"theme": {
"background": "#ffffff",
"text": "#000000",
"primary": "#475569"
},
"typography": {
"font": {
"family": "IBM Plex Sans",
"subset": "latin",
"variants": [
"regular"
],
"size": 13
},
"lineHeight": 1.75,
"hideIcons": false,
"underlineLinks": true
},
"notes": ""
}
}