orchestrator initial commit...
This commit is contained in:
parent
7ab024efda
commit
1b082a3eb6
@ -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
21
orchestrator/.env.example
Normal 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
26
orchestrator/.gitignore
vendored
Normal 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
123
orchestrator/README.md
Normal 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
17
orchestrator/index.html
Normal 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
5403
orchestrator/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
orchestrator/package.json
Normal file
43
orchestrator/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
162
orchestrator/src/client/App.tsx
Normal file
162
orchestrator/src/client/App.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
91
orchestrator/src/client/api/client.ts
Normal file
91
orchestrator/src/client/api/client.ts
Normal 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 || {}),
|
||||
});
|
||||
}
|
||||
1
orchestrator/src/client/api/index.ts
Normal file
1
orchestrator/src/client/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './client';
|
||||
64
orchestrator/src/client/components/Header.tsx
Normal file
64
orchestrator/src/client/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
orchestrator/src/client/components/Icons.tsx
Normal file
118
orchestrator/src/client/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
174
orchestrator/src/client/components/JobCard.tsx
Normal file
174
orchestrator/src/client/components/JobCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
orchestrator/src/client/components/JobList.tsx
Normal file
90
orchestrator/src/client/components/JobList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
orchestrator/src/client/components/ScoreIndicator.tsx
Normal file
37
orchestrator/src/client/components/ScoreIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
orchestrator/src/client/components/Stats.tsx
Normal file
50
orchestrator/src/client/components/Stats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
orchestrator/src/client/components/StatusBadge.tsx
Normal file
28
orchestrator/src/client/components/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
orchestrator/src/client/components/Toast.tsx
Normal file
44
orchestrator/src/client/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
orchestrator/src/client/components/index.ts
Normal file
8
orchestrator/src/client/components/index.ts
Normal 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';
|
||||
10
orchestrator/src/client/main.tsx
Normal file
10
orchestrator/src/client/main.tsx
Normal 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>
|
||||
);
|
||||
680
orchestrator/src/client/styles/index.css
Normal file
680
orchestrator/src/client/styles/index.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
1
orchestrator/src/server/api/index.ts
Normal file
1
orchestrator/src/server/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { apiRouter } from './routes.js';
|
||||
272
orchestrator/src/server/api/routes.ts
Normal file
272
orchestrator/src/server/api/routes.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
30
orchestrator/src/server/db/index.ts
Normal file
30
orchestrator/src/server/db/index.ts
Normal 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();
|
||||
}
|
||||
77
orchestrator/src/server/db/migrate.ts
Normal file
77
orchestrator/src/server/db/migrate.ts
Normal 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!');
|
||||
58
orchestrator/src/server/db/schema.ts
Normal file
58
orchestrator/src/server/db/schema.ts
Normal 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;
|
||||
71
orchestrator/src/server/index.ts
Normal file
71
orchestrator/src/server/index.ts
Normal 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 ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
1
orchestrator/src/server/pipeline/index.ts
Normal file
1
orchestrator/src/server/pipeline/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './orchestrator.js';
|
||||
283
orchestrator/src/server/pipeline/orchestrator.ts
Normal file
283
orchestrator/src/server/pipeline/orchestrator.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
45
orchestrator/src/server/pipeline/run.ts
Normal file
45
orchestrator/src/server/pipeline/run.ts
Normal 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);
|
||||
});
|
||||
2
orchestrator/src/server/repositories/index.ts
Normal file
2
orchestrator/src/server/repositories/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jobs.js';
|
||||
export * from './pipeline.js';
|
||||
190
orchestrator/src/server/repositories/jobs.ts
Normal file
190
orchestrator/src/server/repositories/jobs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
94
orchestrator/src/server/repositories/pipeline.ts
Normal file
94
orchestrator/src/server/repositories/pipeline.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
112
orchestrator/src/server/services/crawler.ts
Normal file
112
orchestrator/src/server/services/crawler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
5
orchestrator/src/server/services/index.ts
Normal file
5
orchestrator/src/server/services/index.ts
Normal 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';
|
||||
89
orchestrator/src/server/services/notion.ts
Normal file
89
orchestrator/src/server/services/notion.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
144
orchestrator/src/server/services/pdf.ts
Normal file
144
orchestrator/src/server/services/pdf.ts
Normal 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`);
|
||||
}
|
||||
143
orchestrator/src/server/services/scorer.ts
Normal file
143
orchestrator/src/server/services/scorer.ts
Normal 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);
|
||||
}
|
||||
153
orchestrator/src/server/services/summary.ts
Normal file
153
orchestrator/src/server/services/summary.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
orchestrator/src/shared/index.ts
Normal file
1
orchestrator/src/shared/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './types';
|
||||
106
orchestrator/src/shared/types.ts
Normal file
106
orchestrator/src/shared/types.ts
Normal 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;
|
||||
}
|
||||
22
orchestrator/tsconfig.json
Normal file
22
orchestrator/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
orchestrator/tsconfig.server.json
Normal file
10
orchestrator/tsconfig.server.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist/server",
|
||||
"rootDir": "./src/server"
|
||||
},
|
||||
"include": ["src/server/**/*", "src/shared/**/*"]
|
||||
}
|
||||
27
orchestrator/vite.config.ts
Normal file
27
orchestrator/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 & Data Structures, Game Development, Databases, Software Engineering (Agile group project)</p>",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ei2fvjokusg3cfmdyolmgcoz",
|
||||
"visible": false,
|
||||
"institution": " ",
|
||||
"studyType": "",
|
||||
"area": "A Levels",
|
||||
"score": "",
|
||||
"date": "",
|
||||
"summary": "<ul><li><p>Maths: A</p></li><li><p>Computer Science: B</p></li><li><p>Physics: C</p></li><li><p>Chemistry: E</p></li></ul>",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pm4r5hngvv1w4mc79o22irfx",
|
||||
"visible": false,
|
||||
"institution": " ",
|
||||
"studyType": "",
|
||||
"area": "GCSEs",
|
||||
"score": "",
|
||||
"date": "",
|
||||
"summary": "<ol><li><p>English: A*</p></li><li><p>Computer Science: A*</p></li><li><p>Urdu: A</p></li><li><p>Islamiat: A</p></li><li><p>Pakistan Studies: A</p></li><li><p>Biology: A</p></li><li><p>Chemistry: A</p></li><li><p>Physics: A</p></li><li><p>Maths: A</p></li></ol>",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"experience": {
|
||||
"name": "Experience",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "experience",
|
||||
"items": [
|
||||
{
|
||||
"id": "ng9ui2azk7w4y8oyu8kazqeb",
|
||||
"visible": true,
|
||||
"company": "Autodesk",
|
||||
"position": "Software Engineering Intern",
|
||||
"location": "Hybrid (Sheffield Based)",
|
||||
"date": "July 2024 - June 2025",
|
||||
"summary": "<ul><li><p><strong>Implemented front-end features and fixes</strong> in the Autodesk Construction Cloud Model Coordination app, working in a ~10-year-old React/JavaScript/TypeScript codebase (7k+ commits) using Webpack module federation and Autodesk’s 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 & 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 university’s inclusive campus atmosphere, showcasing the university's vibrant community to prospective students.</p></li></ul>",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"volunteer": {
|
||||
"name": "Volunteering",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "volunteer",
|
||||
"items": []
|
||||
},
|
||||
"interests": {
|
||||
"name": "Interests",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": false,
|
||||
"id": "interests",
|
||||
"items": []
|
||||
},
|
||||
"languages": {
|
||||
"name": "Languages",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "languages",
|
||||
"items": []
|
||||
},
|
||||
"profiles": {
|
||||
"name": "Profiles",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "profiles",
|
||||
"items": [
|
||||
{
|
||||
"id": "ukl0uecvzkgm27mlye0wazlb",
|
||||
"visible": true,
|
||||
"network": "GitHub",
|
||||
"username": "DaKheera47",
|
||||
"icon": "github",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://github.com/DaKheera47"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cnbk5f0aeqvhx69ebk7hktwd",
|
||||
"visible": true,
|
||||
"network": "LinkedIn",
|
||||
"username": "ssarfaraz30",
|
||||
"icon": "linkedin",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://www.linkedin.com/in/ssarfaraz30/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "linnyxv78zdep1xwirpa2ia1",
|
||||
"visible": true,
|
||||
"network": "Hashnode",
|
||||
"username": "DaKheera47",
|
||||
"icon": "hashnode",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://dakheera47.hashnode.dev/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"projects": {
|
||||
"name": "Projects",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "projects",
|
||||
"items": [
|
||||
{
|
||||
"id": "yw843emozcth8s1ubi1ubvlf",
|
||||
"visible": false,
|
||||
"name": "Atoro",
|
||||
"description": "Lead Developer",
|
||||
"date": "January 2023",
|
||||
"summary": "<ol><li><p><strong>Next.js Implementation for Enhanced SEO:</strong> Utilized Next.js to optimize the website for search engines, significantly improving its online visibility and user engagement.</p></li><li><p><strong>Strapi Backend Integration:</strong> Streamlined content management by implementing a Strapi backend, enhancing the efficiency and scalability of the website's content updates.</p></li><li><p><strong>Responsive Design with Tailwind CSS:</strong> Employed Tailwind CSS for a utility-first approach, ensuring the website's responsiveness and seamless user experience across various devices.</p></li><li><p><strong>Continuous Deployment Pipeline Establishment:</strong> Developed a continuous deployment pipeline, ensuring real-time updates and maintaining high performance and reliability of the website.</p></li><li><p><strong>Optimized Web Performance:</strong> Focused on optimizing web performance by efficiently loading images and managing JavaScript bundles, leading to a faster and more efficient user experience.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://atoro.promirage.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ncxgdjjky54gh59iz2t1xi1v",
|
||||
"visible": false,
|
||||
"name": "Stellar Consultancy",
|
||||
"description": "Lead Developer",
|
||||
"date": "April 2023",
|
||||
"summary": "<ol><li><p><strong>WordPress and Elementor Integration:</strong> Expertly utilized WordPress with Elementor to build a robust content management system, enhancing the website's scalability and user interaction capabilities.</p></li><li><p><strong>Client Engagement and Trust Building:</strong> Implemented features to showcase client testimonials, effectively building trust and displaying the success of previous project engagements.</p></li><li><p><strong>Intuitive Design and User Engagement:</strong> Focused on intuitive page design and structuring, streamlining site maintenance and content updates, thereby enhancing user engagement.</p></li><li><p><strong>Effective Call-to-Actions:</strong> Crafted clear call-to-actions and provided essential contact information, significantly improving user interaction and conversion rates.</p></li><li><p><strong>Portfolio Display for Business Showcase:</strong> Presented past work and services offered through a comprehensive portfolio display, allowing visitors to assess the quality and impact of Stellar Consultancy's services.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://stellarconsultancy.ca"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tcecguinuctb8mu2xqrn97m8",
|
||||
"visible": true,
|
||||
"name": "Mumtaz Urdu",
|
||||
"description": "Developer",
|
||||
"date": "July 2022",
|
||||
"summary": "<ol><li><p><strong>Server-Rendered Web Application Development</strong>: Created the Mumtaz Urdu platform with Next.js to optimize server-side rendering for enhanced SEO and performance.</p></li><li><p><strong>UI Development with Tailwind CSS</strong>: Implemented utility-first Tailwind CSS, ensuring rapid, responsive design for a seamless user interface.</p></li><li><p><strong>Scalable Storage Solution</strong>: Integrated scalable Amazon S3 storage, supporting the application's growth and robust data management.</p></li><li><p><strong>Progressive Web App Implementation</strong>: Developed PWA features for Mumtaz Urdu, offering users native-like mobile access and increased engagement.</p></li><li><p><strong>High Traffic Data Management</strong>: Engineered Mumtaz Urdu's backend with Next.js and MongoDB, enabling the handling and efficient processing of vast amounts of user data for thousands of monthly users.</p></li><li><p><strong>Test-Driven Development</strong>: Embraced TDD practices to ensure reliable and high-quality code, facilitating regular testing throughout the development process for continuous improvement.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://www.mumtazurdu.com/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "to47h749kaj6t02j3f9kprxq",
|
||||
"visible": false,
|
||||
"name": "PyScreeze",
|
||||
"description": "Open Source Contribution",
|
||||
"date": "January 2022",
|
||||
"summary": "<ol><li><p><strong>Innovative Feature Implementation:</strong> Implemented the <code>locateCenterOnScreenNear</code> function for <code>PyScreeze</code>, enhancing the library's functionality by enabling precise image location near a specified point on the screen.</p></li><li><p><strong>Open Source Contribution:</strong> Marked my debut in open-source contributions with this significant addition to <code>PyScreeze</code>, showcasing my initiative and ability to contribute effectively to community-driven projects.</p></li><li><p><strong>Collaborative Development and Recognition:</strong> Collaborated with the project's maintainer, <code>asweigart</code>, to refine and integrate the function into the main codebase, receiving recognition for this valuable contribution to the project.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://github.com/asweigart/pyscreeze/pull/79"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gt7yq82ulor5hmmutdhuvfo1",
|
||||
"visible": false,
|
||||
"name": "Threegency",
|
||||
"description": "Lead Developer",
|
||||
"date": "February 2023",
|
||||
"summary": "<ul><li><p><strong>Framework</strong>: Utilized Next.js to build a server-rendered React website, enhancing SEO and ensuring optimal performance.</p></li><li><p><strong>Styling</strong>: Employed Tailwind CSS for utility-first styling, facilitating rapid UI development.</p></li><li><p><strong>Content Management</strong>: Leveraged Strapi as a CMS, enabling streamlined content updates and administration.</p></li><li><p><strong>Data Handling</strong>: Utilized GraphQL for data handling, ensuring efficient and flexible data retrieval.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://www.threegency.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c8fcu3nz541a4d5zcurx6b8c",
|
||||
"visible": false,
|
||||
"name": "AutoClass",
|
||||
"description": "GUI Automation",
|
||||
"date": "November 2021",
|
||||
"summary": "<ul><li><p><strong>Framework</strong>: Written in Python, leveraging the versatility and ease-of-use of the language.</p></li><li><p><strong>Automation Library</strong>: Utilized PyAutoGUI for automating user interactions, enhancing the utility of the application.</p></li><li><p><strong>Iterative Improvement</strong>: Progressively refined over a year, demonstrating a commitment to robustness and reliability.</p></li><li><p><strong>Project Purpose</strong>: Developed to automate the process of joining Zoom classes, alleviating the repetitive morning routine.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://github.com/DaKheera47/autoclass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rv23bgibq6bye6rujmcx1ygc",
|
||||
"visible": false,
|
||||
"name": "Meet Link Generator",
|
||||
"description": "GUI Automation",
|
||||
"date": "January 2022",
|
||||
"summary": "<ul><li><p><strong>Functionality</strong>: Generates Google Meet links with specific words in the URL by brute-forcing the creation of thousands of links until the desired pattern is achieved. Doing so enables creation of Google Meet links with specific codes or phrases.</p></li><li><p><strong>Optimized Automation</strong>: The final product uses Python with PyAutoGUI for efficient and rapid creation of new Google Meet links.</p></li><li><p><strong>Speed and Efficiency</strong>: Drastically improved performance, finally achieving the link generation time to under 1 second per link, limited only by internet speed.</p></li><li><p><strong>Interface Interaction</strong>: Utilizes the Google Meet homepage's features for quicker link generation, avoiding full page refreshes for speed.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://github.com/DaKheera47/meet-link-generator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tu98rghbi5c43ogget5mh7ih",
|
||||
"visible": false,
|
||||
"name": "UCLan Server-side Web Application Project",
|
||||
"description": "",
|
||||
"date": "UCLan Year 1",
|
||||
"summary": "<ul><li><p><strong>Backend Development with PHP and MySQL:</strong> Developed the backend for a Student’s Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.</p></li><li><p><strong>User Authentication and Session Management:</strong> Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.</p></li><li><p><strong>Dynamic Content Display from Database:</strong> Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.</p></li><li><p><strong>Advanced Search and Personalization Features:</strong> Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ov4lkbc1vl169ynfnj91m1lm",
|
||||
"visible": false,
|
||||
"name": "Square About",
|
||||
"description": "",
|
||||
"date": "UCLan Year 1",
|
||||
"summary": "<ul><li><p><strong>Advanced 3D Game Development:</strong> Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.</p></li><li><p><strong>Dynamic Gameplay Elements:</strong> Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.</p></li><li><p><strong>Interactive Game Controls:</strong> Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.</p></li><li><p><strong>Strategic Game Mechanics:</strong> Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "s3r37gdr0oa84a6dp6r5nl58",
|
||||
"visible": false,
|
||||
"name": "Car Smash",
|
||||
"description": "",
|
||||
"date": "UCLan Year 1",
|
||||
"summary": "<ol><li><p><strong>3D Car Smash Game Development:</strong> Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.</p></li><li><p><strong>Collision Detection Mechanics:</strong> Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.</p></li><li><p><strong>Dynamic Game States and Camera Views:</strong> Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.</p></li><li><p><strong>Enhanced Player Interaction:</strong> Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gylzkvl103m9s7ywag4xpdy4",
|
||||
"visible": false,
|
||||
"name": "Tweet Filter",
|
||||
"description": "",
|
||||
"date": "UCLan Year 1",
|
||||
"summary": "<ol><li><p><strong>Tweet Filtration System:</strong> Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.</p></li><li><p><strong>Advanced Text Manipulation:</strong> Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.</p></li><li><p><strong>Output Generation:</strong> Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.</p></li><li><p><strong>Algorithm Optimization:</strong> Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "enav754zxhuc9uycbb83s94q",
|
||||
"visible": false,
|
||||
"name": "Burger Ordering App",
|
||||
"description": "",
|
||||
"date": "UCLan Year 1",
|
||||
"summary": "<ol><li><p><strong>Interactive Console Application:</strong> Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.</p></li><li><p><strong>Complex Logic Implementation:</strong> Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.</p></li><li><p><strong>Data Handling and User Input:</strong> Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.</p></li><li><p><strong>Readable and Maintainable Code:</strong> Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hl6jgeswr01tlul3iwoat05d",
|
||||
"visible": false,
|
||||
"name": "LinkLander",
|
||||
"description": "Android Studio, Kotlin",
|
||||
"date": "December 2023 - Ongoing",
|
||||
"summary": "<ul><li><p><strong>Innovative Android Utility:</strong> Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.</p></li><li><p><strong>User-Centric Design:</strong> Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.</p></li><li><p><strong>Simplicity and Efficiency:</strong> Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.</p></li><li><p><strong>Technical Proficiency in Kotlin:</strong> Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "v4s0ljbiiio198y8l1wl0ym6",
|
||||
"visible": false,
|
||||
"name": "AR App Development with AGILE",
|
||||
"description": "Unity, C#",
|
||||
"date": "October 2023 - Ongoing",
|
||||
"summary": "<ul><li><p><strong>Agile Development in Action</strong>: Participated in an Agile team project, developing an AR application for supporting disabled students with a team of five, demonstrating an application of Agile methodologies in a real-world scenario.</p></li><li><p><strong>Mobile AR Application Prototype</strong>: Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.</p></li><li><p><strong>Collaborative Software Engineering</strong>: Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.</p></li><li><p><strong>Presentation and Critical Analysis</strong>: Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.</p></li></ul>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fwxrq682hqrj1y76rmziqrbk",
|
||||
"visible": true,
|
||||
"name": "Indus Marine Services",
|
||||
"description": "System Design & Development",
|
||||
"date": "May 2022 - Ongoing",
|
||||
"summary": "<ol><li><p><strong>Induction System for Marine Services</strong>: Designed & developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.</p></li><li><p><strong>Admin-Centric Functionality</strong>: Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements</p></li><li><p><strong>Client Engagement Interface</strong>: Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.</p></li><li><p><strong>Robust Tech Stack Integration</strong>: Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "http://www.ims-auh.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jdfyaez8vq1b7xfr9rmxmz06",
|
||||
"visible": false,
|
||||
"name": "VECTOR AI",
|
||||
"description": "Website Development",
|
||||
"date": "February 2024 - February 2024",
|
||||
"summary": "<ol><li><p><strong>Innovative AI Development</strong>: As the driving force behind VECTOR's website development, I spearheaded the technical design using Astro, with a cutting-edge stack including React and Tailwind CSS.</p></li><li><p><strong>Data-Driven Content Strategy</strong>: Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.</p></li><li><p><strong>Astro for Enhanced Performance</strong>: Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience</p></li><li><p><strong>React for Responsive Interaction</strong>: Utilized React’s robust ecosystem to develop interactive elements, ensuring that each module of VECTOR’s platform is engaging and seamless for users across various touchpoints.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://vector-ai.co/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "qdhmfkqpfql19ohfas1g91ek",
|
||||
"visible": false,
|
||||
"name": "UCLan's First Hackathon",
|
||||
"description": "Hackathon, Team Work",
|
||||
"date": "February 2024",
|
||||
"summary": "<ol><li><p><strong>Second Place in UCLan Hackathon</strong>: Earned second place in UCLan's first hackathon by developing an app to simplify university life. Focused on enhancing the attendance monitoring process for student mentors.</p></li><li><p><strong>TRPC for End-to-End Type Safety</strong>: Utilized TRPC to ensure end-to-end type safety, enhancing the app's reliability and streamlining the development process.</p></li><li><p><strong>Supabase Backend Integration</strong>: Implemented Supabase as a backend solution, providing a robust and scalable database for managing attendance data efficiently.</p></li><li><p><strong>Amazon SES and OAuth Integration</strong>: Integrated Amazon SES for email notifications and OAuth for secure Google login, improving user experience and communication.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rw3x7tapntrt877rbl4pnxz7",
|
||||
"visible": true,
|
||||
"name": "NASA Space Apps Challenge",
|
||||
"description": "A 48-hour, global hackathon powered by NASA open data",
|
||||
"date": "Oct 4–5, 2025",
|
||||
"summary": "<ol><li><p><strong>Full-Stack Integration:</strong> Wired up backend services to a responsive frontend, enabling real-time exploration of <strong>Kepler/K2/TESS</strong> catalogs and smooth model-scoring UX.</p></li><li><p><strong>Data Harmonization Pipeline:</strong> Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by <strong>60%+</strong> during the hack.</p></li><li><p><strong>Analytics UI & Upload Flow:</strong> Built an upload → 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 & Maintenance:</strong> Regularly updated and used in live deployment at <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"http://lifting.dakheera47.com\">lifting.dakheera47.com</a>, tracking <strong>hundreds of sets</strong> over time with persistent analytics and performance trends.</p></li></ol>",
|
||||
"keywords": [],
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": "https://lifting.dakheera47.com/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"publications": {
|
||||
"name": "Publications",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "publications",
|
||||
"items": []
|
||||
},
|
||||
"references": {
|
||||
"name": "References",
|
||||
"columns": 1,
|
||||
"separateLinks": true,
|
||||
"visible": false,
|
||||
"id": "references",
|
||||
"items": [
|
||||
{
|
||||
"id": "f2sv5z0cce6ztjl87yuk8fak",
|
||||
"visible": true,
|
||||
"name": "Available upon request",
|
||||
"description": "",
|
||||
"summary": "",
|
||||
"url": {
|
||||
"label": "",
|
||||
"href": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"skills": {
|
||||
"name": "Skills",
|
||||
"columns": 2,
|
||||
"separateLinks": true,
|
||||
"visible": true,
|
||||
"id": "skills",
|
||||
"items": [
|
||||
{
|
||||
"id": "jfgzfcwcg65k9gemuxlfe9m3",
|
||||
"visible": true,
|
||||
"name": "Frontend Development",
|
||||
"description": "",
|
||||
"level": 0,
|
||||
"keywords": [
|
||||
"React",
|
||||
"Next.js",
|
||||
"Tailwind CSS",
|
||||
"Strapi CMS",
|
||||
"Elementor",
|
||||
"GraphQL",
|
||||
"TypeScript",
|
||||
"CI/CD",
|
||||
"PWA Development",
|
||||
"AstroJS",
|
||||
"React Testing Library"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sk3957foopxir2hw4xzxqahh",
|
||||
"visible": true,
|
||||
"name": "Backend Development",
|
||||
"description": "",
|
||||
"level": 0,
|
||||
"keywords": [
|
||||
"Node.js",
|
||||
"Express.js",
|
||||
"MongoDB",
|
||||
"Supabase",
|
||||
"Firebase",
|
||||
"Docker",
|
||||
"FastAPI",
|
||||
"AWS S3",
|
||||
"AWS SES"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d9bddwdj6qreknhk644rm0bs",
|
||||
"visible": true,
|
||||
"name": "Leadership and Problem-Solving",
|
||||
"description": "",
|
||||
"level": 0,
|
||||
"keywords": [
|
||||
"Agile Project Management",
|
||||
"Conflict Resolution",
|
||||
"Creative Problem-Solving",
|
||||
"Decision-Making",
|
||||
"Effective Communication",
|
||||
"Adaptability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gk4hrky0wnbsbdcmmud48zjh",
|
||||
"visible": true,
|
||||
"name": "Other Programming",
|
||||
"description": "",
|
||||
"level": 0,
|
||||
"keywords": [
|
||||
"Python Scripting",
|
||||
"PyAutoGUI",
|
||||
"Git",
|
||||
"GitHub",
|
||||
"Selenium",
|
||||
"Data Analysis",
|
||||
"Web Scraping",
|
||||
"Data Cleaning"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"custom": {}
|
||||
},
|
||||
"metadata": {
|
||||
"template": "onyx",
|
||||
"layout": [
|
||||
[
|
||||
[
|
||||
"summary",
|
||||
"education",
|
||||
"experience",
|
||||
"projects",
|
||||
"references"
|
||||
],
|
||||
[
|
||||
"profiles",
|
||||
"skills",
|
||||
"certifications",
|
||||
"interests",
|
||||
"languages",
|
||||
"awards",
|
||||
"volunteer",
|
||||
"publications"
|
||||
]
|
||||
]
|
||||
],
|
||||
"css": {
|
||||
"value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
|
||||
"visible": false
|
||||
},
|
||||
"page": {
|
||||
"margin": 34,
|
||||
"format": "a4",
|
||||
"options": {
|
||||
"breakLine": false,
|
||||
"pageNumbers": false
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"background": "#ffffff",
|
||||
"text": "#000000",
|
||||
"primary": "#475569"
|
||||
},
|
||||
"typography": {
|
||||
"font": {
|
||||
"family": "IBM Plex Sans",
|
||||
"subset": "latin",
|
||||
"variants": [
|
||||
"regular"
|
||||
],
|
||||
"size": 13
|
||||
},
|
||||
"lineHeight": 1.75,
|
||||
"hideIcons": false,
|
||||
"underlineLinks": true
|
||||
},
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user