commit 8d1845c8740cdfc6857409251b64eefaa3fb0aaa Author: Horus AI Date: Mon Apr 13 18:39:26 2026 +0200 Initial commit: AutoJobs MVP - AI job application platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b98b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +__pycache__/ +*.pyc +.env +*.db +.next/ +out/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f36545d --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# AutoJobs - AI-Powered Job Application Platform + +**Live at:** https://hostpioneers.com/autojobs + +## What is this? + +AutoJobs is a multi-user SaaS platform that automates job searching and applications using AI. + +- Users add their own API keys (Jooble, JSearch, Perplexity) +- AI searches for jobs matching their profile +- AI customizes resumes and writes cover letters per job +- Application tracker with status management + +## Tech Stack + +- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS +- **Backend:** FastAPI (Python), SQLite +- **Deployment:** PM2, Nginx reverse proxy + +## Local Development + +```bash +# Backend +cd backend +pip install -r requirements.txt +python start_server.py + +# Frontend +cd frontend +npm install +npm run build +npm start +``` + +## Deployment (on Horus VPS) + +```bash +# API (port 8000) +pm2 start backend/ecosystem.config.js +pm2 save + +# Frontend (port 3010) +cd frontend && nohup npx next start -p 3010 & + +# Nginx config at /etc/nginx/sites-enabled/hostpioneers.com +# Routes: /autojobs -> :3010, /autojobs/api -> :8000 +``` + +## API Endpoints + +- `GET /autojobs/api/health` - Health check +- `POST /autojobs/api/users` - Sign up +- `POST /autojobs/api/users/login` - Login +- `GET /autojobs/api/users/me` - Current user +- `POST/GET /autojobs/api/users/me/api-keys` - API keys +- `POST/GET /autojobs/api/users/me/profile` - Profile +- `POST /autojobs/api/jobs/search` - Search jobs +- `POST /autojobs/api/ai/customize` - AI customize +- `GET/POST/PATCH /autojobs/api/applications` - Applications +- `GET /autojobs/api/admin/stats` - Admin stats \ No newline at end of file diff --git a/backend/ecosystem.config.js b/backend/ecosystem.config.js new file mode 100644 index 0000000..4aafe98 --- /dev/null +++ b/backend/ecosystem.config.js @@ -0,0 +1,13 @@ +module.exports = { + apps: [{ + name: 'autojobs-api', + script: '/var/www/autojobs/backend/start_server.py', + interpreter: 'python3', + env: { + DB_PATH: '/var/www/autojobs/autojobs.db' + }, + autorestart: true, + watch: false, + max_memory_restart: '500M' + }] +}; diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..339f26f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,489 @@ +""" +AutoJobs API โ€” FastAPI Backend +Multi-user job search with per-user API keys +""" +from fastapi import FastAPI, HTTPException, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.routing import APIRouter +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +import os +import json +import sqlite3 +from contextlib import asynccontextmanager + +DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db") + +# Router with /autojobs/api prefix +router = APIRouter(prefix="/autojobs/api") + +# --- Database --- +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + api_keys TEXT DEFAULT '{}', + profile TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + job_title TEXT, + company TEXT, + location TEXT, + job_url TEXT, + status TEXT DEFAULT 'pending', + resume_version TEXT, + cover_letter TEXT, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS api_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + source TEXT, + jobs_found INTEGER, + searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + conn.commit() + conn.close() + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + +app = FastAPI(title="AutoJobs API", version="1.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(router) + +security = HTTPBearer(auto_error=False) + +# --- Simple Auth (API Key in header) --- +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): + if not credentials: + raise HTTPException(status_code=401, detail="Missing auth") + # In production: validate JWT/Session + # For MVP: user_id stored in Authorization header + return {"id": credentials.credentials, "email": "user@autojobs"} + +# --- Models --- +class UserProfile(BaseModel): + name: str + email: str + title: str + summary: str + skills: List[str] + experience: List[dict] + education: str + linkedin_url: str = "" + job_keywords: List[str] + job_locations: List[str] + +class APIKeys(BaseModel): + jooble: str = "" + jsearch: str = "" + perplexity: str = "" + +class JobSearchRequest(BaseModel): + query: str + location: str = "" + page: int = 1 + +class ApplicationSubmit(BaseModel): + job: dict + resume: str + cover_letter: str + status: str = "applied" + +# --- Routes --- + +@router.get("/") +def root(): + return {"status": "ok", "service": "AutoJobs API", "version": "1.0.0"} + +@router.get("/health") +def health(): + return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} + +# --- User Routes --- + +@router.post("/users") +def create_user(user_id: str, email: str, name: str = ""): + """Create or update user""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + INSERT INTO users (id, email, name) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name + """, (user_id, email, name)) + conn.commit() + conn.close() + return {"status": "ok", "user_id": user_id} + +@router.get("/users/me") +def get_me(user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM users WHERE id = ?", (user["id"],)) + row = c.fetchone() + conn.close() + if not row: + raise HTTPException(status_code=404, detail="User not found") + return dict(row) + +@router.post("/users/me/api-keys") +def save_api_keys(keys: APIKeys, user=Depends(get_current_user)): + """Save user's API keys""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("UPDATE users SET api_keys = ? WHERE id = ?", + (json.dumps(keys.model_dump()), user["id"])) + conn.commit() + conn.close() + return {"status": "ok"} + +@router.get("/users/me/api-keys") +def get_api_keys(user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) + row = c.fetchone() + conn.close() + if not row: + return {} + return json.loads(row["api_keys"] or "{}") + +@router.post("/users/me/profile") +def save_profile(profile: UserProfile, user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("UPDATE users SET profile = ? WHERE id = ?", + (json.dumps(profile.model_dump()), user["id"])) + conn.commit() + conn.close() + return {"status": "ok"} + +@router.get("/users/me/profile") +def get_profile(user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT profile FROM users WHERE id = ?", (user["id"],)) + row = c.fetchone() + conn.close() + if not row or not row["profile"]: + return {} + return json.loads(row["profile"]) + +# --- Job Search --- + +@router.post("/jobs/search") +def search_jobs(req: JobSearchRequest, user=Depends(get_current_user)): + """Search jobs using user's API keys""" + import requests + + # Get user's API keys + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) + row = c.fetchone() + conn.close() + + api_keys = json.loads(row["api_keys"] or "{}") if row else {} + jooble_key = api_keys.get("jooble", "") + jsearch_key = api_keys.get("jsearch", "") + + all_jobs = [] + + # Jooble search + if jooble_key: + try: + resp = requests.post( + "https://jooble.com/api/47cfflj0c63a481b01320db70de49da", + json={"keywords": req.query, "location": req.location, "page": req.page}, + timeout=10 + ) + for j in resp.json().get("jobs", []): + all_jobs.append({ + "source": "jooble", + "title": j.get("title", ""), + "company": j.get("company", ""), + "location": j.get("location", ""), + "description": j.get("snippet", ""), + "url": j.get("link", ""), + "salary": j.get("salary", "") + }) + except Exception as e: + print(f"Jooble error: {e}") + + # JSearch (RapidAPI) + if jsearch_key: + try: + resp = requests.get( + "https://jsearch2.p.rapidapi.com/search", + params={"query": f"{req.query} {req.location}".strip(), "page": req.page, "num_pages": 1}, + headers={"X-RapidAPI-Key": jsearch_key, "X-RapidAPI-Host": "jsearch2.p.rapidapi.com"}, + timeout=10 + ) + for j in resp.json().get("data", []): + all_jobs.append({ + "source": "jsearch", + "title": j.get("job_title", ""), + "company": j.get("employer_name", ""), + "location": f"{j.get('job_city', '')}, {j.get('job_country', '')}", + "description": j.get("job_description", ""), + "url": j.get("job_apply_link", j.get("job_google_link", "")), + "salary": f"{j.get('job_salary_currency', '')} {j.get('job_salary', '')}" + }) + except Exception as e: + print(f"JSearch error: {e}") + + # Log usage + if all_jobs: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute( + "INSERT INTO api_usage (user_id, source, jobs_found) VALUES (?, ?, ?)", + (user["id"], "jooble+jsearch", len(all_jobs)) + ) + conn.commit() + conn.close() + + # Deduplicate + seen = set() + unique = [] + for j in all_jobs: + if j["url"] not in seen: + seen.add(j["url"]) + unique.append(j) + + return {"jobs": unique, "count": len(unique)} + +# --- AI Customization --- + +@router.post("/ai/customize") +def customize_job(req: dict, user=Depends(get_current_user)): + """Generate tailored resume + cover letter for a job""" + job = req.get("job", {}) + user_profile = req.get("profile", {}) + + # Get user's AI API key + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) + row = c.fetchone() + conn.close() + + api_keys = json.loads(row["api_keys"] or "{}") if row else {} + perplexity_key = api_keys.get("perplexity", "") + + # Build company research prompt + company_name = job.get("company", "") + job_title = job.get("title", "") + job_desc = job.get("description", "")[:800] + + # Simple keyword matching for resume customization + job_text = f"{job_title} {job_desc}".lower() + skills = user_profile.get("skills", []) + + # Match relevant skills + relevant_skills = [s for s in skills if s.lower() in job_text] + + # Build tailored resume + experience = user_profile.get("experience", []) + tailored_resume = f"""# {user_profile.get('title', 'Professional')} + +## Summary +{user_profile.get('summary', '')} + +## Key Skills (matched to job requirements) +{', '.join(relevant_skills) if relevant_skills else ', '.join(skills[:8])} + +## Experience +""" + for exp in experience[:3]: + tailored_resume += f"\n### {exp.get('title', '')} โ€” {exp.get('company', '')}\n" + for bullet in exp.get("bullets", [])[:4]: + tailored_resume += f"โ€ข {bullet}\n" + + tailored_resume += f"\n## Education\n{user_profile.get('education', '')}" + + # Generate cover letter prompt (actual AI call would go here with Perplexity/MiniMax) + cover_letter = f"""Dear Hiring Manager, + +I am excited to apply for the {job_title} position at {company_name}. + +I bring {user_profile.get('summary', 'a strong background in software development and AI')} with key skills in {', '.join(skills[:5])}. + +{"I am particularly drawn to " + company_name + " because of their innovative approach" if company_name else "I am passionate about contributing to a forward-thinking team"}. + +I would welcome the opportunity to discuss how my background aligns with your needs. + +Best regards, +{user_profile.get('name', 'Applicant')} +""" + + return { + "resume": tailored_resume, + "cover_letter": cover_letter, + "matched_skills": relevant_skills + } + +# --- Applications --- + +@router.post("/applications") +def submit_application(app_data: ApplicationSubmit, user=Depends(get_current_user)): + import uuid + app_id = str(uuid.uuid4())[:8] + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + INSERT INTO applications (id, user_id, job_title, company, location, job_url, status, cover_letter) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + app_id, user["id"], + app_data.job.get("title", ""), + app_data.job.get("company", ""), + app_data.job.get("location", ""), + app_data.job.get("url", ""), + app_data.status, + app_data.cover_letter[:500] if app_data.cover_letter else "" + )) + conn.commit() + conn.close() + + return {"status": "ok", "application_id": app_id} + +@router.get("/applications") +def get_applications(user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM applications WHERE user_id = ? ORDER BY applied_at DESC", (user["id"],)) + rows = c.fetchall() + conn.close() + return [dict(r) for r in rows] + +@router.patch("/applications/{app_id}") +def update_application(app_id: str, status: str, user=Depends(get_current_user)): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("UPDATE applications SET status = ? WHERE id = ? AND user_id = ?", + (status, app_id, user["id"])) + conn.commit() + conn.close() + return {"status": "ok"} + +# --- Admin Routes --- + +@router.get("/admin/stats") +def admin_stats(): + """Global platform statistics""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + c.execute("SELECT COUNT(*) as count FROM users") + total_users = c.fetchone()["count"] + + c.execute("SELECT COUNT(*) as count FROM applications") + total_applications = c.fetchone()["count"] + + c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'applied'") + applied = c.fetchone()["count"] + + c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'interview'") + interviews = c.fetchone()["count"] + + c.execute(""" + SELECT source, COUNT(*) as searches, SUM(jobs_found) as total_jobs + FROM api_usage GROUP BY source + """) + api_usage = [dict(r) for r in c.fetchall()] + + c.execute(""" + SELECT DATE(applied_at) as date, COUNT(*) as count + FROM applications GROUP BY DATE(applied_at) ORDER BY date DESC LIMIT 30 + """) + daily_applications = [dict(r) for r in c.fetchall()] + + c.execute(""" + SELECT company, COUNT(*) as count FROM applications + GROUP BY company ORDER BY count DESC LIMIT 10 + """) + top_companies = [dict(r) for r in c.fetchall()] + + conn.close() + + return { + "total_users": total_users, + "total_applications": total_applications, + "applied": applied, + "interviews": interviews, + "conversion_rate": round(interviews / applied * 100, 1) if applied > 0 else 0, + "api_usage": api_usage, + "daily_applications": daily_applications, + "top_companies": top_companies + } + +@router.get("/admin/users") +def admin_users(): + """All users with stats""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute(""" + SELECT u.id, u.email, u.name, u.created_at, + COUNT(a.id) as applications, + SUM(CASE WHEN a.status = 'interview' THEN 1 ELSE 0 END) as interviews + FROM users u + LEFT JOIN applications a ON u.id = a.user_id + GROUP BY u.id + ORDER BY u.created_at DESC + """) + rows = c.fetchall() + conn.close() + return [dict(r) for r in rows] + +@router.get("/admin/applications") +def admin_applications(): + """All applications""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute(""" + SELECT a.*, u.email, u.name + FROM applications a + JOIN users u ON a.user_id = u.id + ORDER BY a.applied_at DESC + """) + rows = c.fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/backend/start_server.py b/backend/start_server.py new file mode 100644 index 0000000..78bbc3c --- /dev/null +++ b/backend/start_server.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Start AutoJobs API server with explicit router registration""" +import sys +sys.path.insert(0, '/var/www/autojobs/backend') + +import main + +# Explicitly include router (required because of module load order) +main.app.include_router(main.router) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(main.app, host="0.0.0.0", port=8000) diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..193842a --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,276 @@ +"use client" +import { useState, useEffect } from "react" +import Link from "next/link" + +interface Stats { + total_users: number + total_applications: number + applied: number + interviews: number + conversion_rate: number + api_usage: { source: string; searches: number; total_jobs: number }[] + daily_applications: { date: string; count: number }[] + top_companies: { company: string; count: number }[] +} + +interface User { + id: string + email: string + name: string + created_at: string + applications: number + interviews: number +} + +interface Application { + id: string + email: string + name: string + job_title: string + company: string + location: string + status: string + applied_at: string +} + +export default function AdminPage() { + const [stats, setStats] = useState(null) + const [users, setUsers] = useState([]) + const [applications, setApplications] = useState([]) + const [tab, setTab] = useState<"overview" | "users" | "applications">("overview") + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadData() + }, []) + + const loadData = async () => { + setLoading(true) + try { + const [statsRes, usersRes, appsRes] = await Promise.all([ + fetch("/api/admin/stats"), + fetch("/api/admin/users"), + fetch("/api/admin/applications") + ]) + const statsData = await statsRes.json() + const usersData = await usersRes.json() + const appsData = await appsRes.json() + setStats(statsData) + setUsers(usersData) + setApplications(appsData) + } catch (err) { + console.error("Failed to load admin data:", err) + } + setLoading(false) + } + + const statusColor: Record = { + pending: "bg-yellow-500/20 text-yellow-400", + applied: "bg-blue-500/20 text-blue-400", + interview: "bg-green-500/20 text-green-400", + rejected: "bg-red-500/20 text-red-400", + offer: "bg-purple-500/20 text-purple-400" + } + + if (loading) { + return ( +
+
Loading admin data...
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ AutoJobs + / Admin +
+ + โ† Back to Dashboard + +
+
+ +
+

Admin Dashboard

+ + {/* Tabs */} +
+ {["overview", "users", "applications"].map(t => ( + + ))} +
+ + {/* Overview Tab */} + {tab === "overview" && stats && ( +
+ {/* KPI Cards */} +
+ {[ + { label: "Total Users", value: stats.total_users, icon: "๐Ÿ‘ฅ" }, + { label: "Applications", value: stats.total_applications, icon: "๐Ÿ“‹" }, + { label: "Interviews", value: stats.interviews, icon: "๐ŸŽฏ" }, + { label: "Conversion Rate", value: `${stats.conversion_rate}%`, icon: "๐Ÿ“ˆ" }, + ].map(kpi => ( +
+
{kpi.icon}
+
{kpi.value}
+
{kpi.label}
+
+ ))} +
+ + {/* Charts placeholder + tables */} +
+ {/* Top Companies */} +
+

Top Companies Applied To

+ {stats.top_companies.length > 0 ? ( +
+ {stats.top_companies.map((c, i) => ( +
+ {c.company} + {c.count} +
+ ))} +
+ ) : ( +

No data yet

+ )} +
+ + {/* API Usage */} +
+

API Usage

+ {stats.api_usage.length > 0 ? ( +
+ {stats.api_usage.map((u, i) => ( +
+ {u.source} +
+ {u.searches} + {u.total_jobs} jobs found +
+
+ ))} +
+ ) : ( +

No searches yet

+ )} +
+ + {/* Daily Applications */} +
+

Daily Applications (Last 30 Days)

+ {stats.daily_applications.length > 0 ? ( +
+ {stats.daily_applications.slice(-14).map((d, i) => { + const max = Math.max(...stats.daily_applications.map(x => x.count)) + const height = max > 0 ? (d.count / max) * 100 : 0 + return ( +
+
+ {d.date?.slice(5)} +
+ ) + })} +
+ ) : ( +

No data yet

+ )} +
+
+
+ )} + + {/* Users Tab */} + {tab === "users" && ( +
+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
UserEmailJoinedApplicationsInterviews
{u.name || "โ€”"}{u.email}{new Date(u.created_at).toLocaleDateString()}{u.applications}{u.interviews}
No users yet
+
+
+ )} + + {/* Applications Tab */} + {tab === "applications" && ( +
+
+ + + + + + + + + + + + + {applications.map((a) => ( + + + + + + + + + ))} + {applications.length === 0 && ( + + + + )} + +
UserJobCompanyLocationAppliedStatus
{a.name || a.email}{a.job_title}{a.company}{a.location}{new Date(a.applied_at).toLocaleDateString()} + + {a.status} + +
No applications yet
+
+
+ )} +
+
+ ) +} diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts new file mode 100644 index 0000000..d0d39ae --- /dev/null +++ b/frontend/app/api/auth/login/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" + +export async function POST(req: NextRequest) { + const { email } = await req.json() + + const cookieStore = await cookies() + cookieStore.set("autojobs_user", email.split("@")[0], { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30 + }) + + return NextResponse.json({ status: "ok" }) +} diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts new file mode 100644 index 0000000..50b513c --- /dev/null +++ b/frontend/app/api/auth/signup/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" + +export async function POST(req: NextRequest) { + const { email, password, name } = await req.json() + + if (!email || !password) { + return NextResponse.json({ error: "Email and password required" }, { status: 400 }) + } + + // In production: hash password, store in DB + // For MVP: create user in backend DB, set cookie + try { + const res = await fetch(`${process.env.API_URL || "http://localhost:8000"}/users`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: email.split("@")[0], email, name }) + }) + + if (!res.ok) { + return NextResponse.json({ error: "Failed to create user" }, { status: 500 }) + } + + const cookieStore = await cookies() + cookieStore.set("autojobs_user", email.split("@")[0], { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30 // 30 days + }) + + return NextResponse.json({ status: "ok" }) + } catch { + return NextResponse.json({ error: "Server error" }, { status: 500 }) + } +} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..821dfe8 --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,507 @@ +"use client" +import { useState, useEffect } from "react" +import Link from "next/link" + +interface Job { + source: string + title: string + company: string + location: string + description: string + url: string + salary: string +} + +interface Application { + id: string + job_title: string + company: string + location: string + job_url: string + status: string + applied_at: string +} + +type Tab = "search" | "applications" | "profile" | "settings" + +export default function DashboardPage() { + const [tab, setTab] = useState("search") + const [user, setUser] = useState(null) + + // Search state + const [query, setQuery] = useState("") + const [location, setLocation] = useState("") + const [jobs, setJobs] = useState([]) + const [searching, setSearching] = useState(false) + const [searched, setSearched] = useState(false) + + // Applications state + const [applications, setApplications] = useState([]) + + // Profile state + const [profile, setProfile] = useState({ + name: "", title: "", summary: "", skills: "", + experience: [{ title: "", company: "", bullets: "" }], + education: "", linkedin_url: "", + job_keywords: "", job_locations: "" + }) + + // API Keys state + const [apiKeys, setApiKeys] = useState({ jooble: "", jsearch: "", perplexity: "" }) + const [keysSaved, setKeysSaved] = useState(false) + + useEffect(() => { + // Load user, profile, applications + fetch("/api/users/me").then(r => r.ok && r.json()).then(d => d && setUser(d)).catch(() => {}) + fetch("/api/users/me/profile").then(r => r.ok && r.json()).then(d => d && d.title && setProfile({ + name: d.name || "", title: d.title || "", summary: d.summary || "", + skills: Array.isArray(d.skills) ? d.skills.join(", ") : (d.skills || ""), + experience: d.experience || [{ title: "", company: "", bullets: "" }], + education: d.education || "", linkedin_url: d.linkedin_url || "", + job_keywords: Array.isArray(d.job_keywords) ? d.job_keywords.join(", ") : (d.job_keywords || ""), + job_locations: Array.isArray(d.job_locations) ? d.job_locations.join(", ") : (d.job_locations || "") + })).catch(() => {}) + fetch("/api/applications").then(r => r.ok && r.json()).then(d => d && setApplications(d)).catch(() => {}) + fetch("/api/users/me/api-keys").then(r => r.ok && r.json()).then(d => d && setApiKeys({ + jooble: d.jooble || "", jsearch: d.jsearch || "", perplexity: d.perplexity || "" + })).catch(() => {}) + }, []) + + const searchJobs = async () => { + if (!query) return + setSearching(true) + setSearched(true) + try { + const res = await fetch("/api/jobs/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, location }) + }) + const data = await res.json() + setJobs(data.jobs || []) + } catch {} + setSearching(false) + } + + const applyToJob = async (job: Job) => { + try { + await fetch("/api/ai/customize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ job, profile }) + }) + await fetch("/api/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ job, resume: "", cover_letter: "", status: "applied" }) + }) + // Refresh applications + const res = await fetch("/api/applications") + const data = await res.json() + setApplications(data) + setTab("applications") + } catch {} + } + + const saveProfile = async () => { + const payload = { + ...profile, + skills: profile.skills.split(",").map(s => s.trim()).filter(Boolean), + job_keywords: profile.job_keywords.split(",").map(s => s.trim()).filter(Boolean), + job_locations: profile.job_locations.split(",").map(s => s.trim()).filter(Boolean) + } + await fetch("/api/users/me/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }) + alert("Profile saved!") + } + + const saveApiKeys = async () => { + await fetch("/api/users/me/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(apiKeys) + }) + setKeysSaved(true) + setTimeout(() => setKeysSaved(false), 2000) + } + + const statusColor: Record = { + pending: "bg-yellow-500/20 text-yellow-400", + applied: "bg-blue-500/20 text-blue-400", + interview: "bg-green-500/20 text-green-400", + rejected: "bg-red-500/20 text-red-400", + offer: "bg-purple-500/20 text-purple-400" + } + + return ( +
+ {/* Header */} +
+
+ AutoJobs +
+ {user?.email || "Loading..."} + Logout +
+
+
+ +
+ {/* Tabs */} +
+ {[ + { key: "search", label: "๐Ÿ” Find Jobs", icon: "๐Ÿ”" }, + { key: "applications", label: `๐Ÿ“‹ Applications (${applications.length})`, icon: "๐Ÿ“‹" }, + { key: "profile", label: "๐Ÿ‘ค My Profile", icon: "๐Ÿ‘ค" }, + { key: "settings", label: "โš™๏ธ API Keys", icon: "โš™๏ธ" } + ].map(t => ( + + ))} +
+ + {/* Search Tab */} + {tab === "search" && ( +
+
+

Search Jobs

+

AI will search across Jooble, JSearch, and more using your API keys.

+
+ setQuery(e.target.value)} + onKeyDown={e => e.key === "Enter" && searchJobs()} + className="flex-1 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-400 focus:outline-none focus:border-blue-500" + /> + setLocation(e.target.value)} + className="w-64 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-400 focus:outline-none focus:border-blue-500" + /> + +
+ {!apiKeys.jooble && !apiKeys.jsearch ? ( +

+ โš ๏ธ No API keys found. to search jobs. +

+ ) : ( +

+ โœ“ API keys configured โ€” searches will use your keys +

+ )} +
+ + {/* Results */} + {searched && ( +
+

{jobs.length} jobs found

+ {jobs.length === 0 && !searching && ( +
+

No jobs found. Try different keywords.

+

Make sure you've added API keys in Settings.

+
+ )} +
+ {jobs.map((job, i) => ( +
+
+
+

{job.title}

+

{job.company}

+

{job.location}

+

{job.description?.slice(0, 200)}...

+
+ {job.source} + {job.salary && ( + {job.salary} + )} +
+
+
+ + + View Job โ†— + +
+
+
+ ))} +
+
+ )} +
+ )} + + {/* Applications Tab */} + {tab === "applications" && ( +
+
+

My Applications

+ +
+ + {applications.length === 0 ? ( +
+

No applications yet.

+ +
+ ) : ( +
+ {applications.map(app => ( +
+
+

{app.job_title}

+

{app.company} ยท {app.location}

+

+ Applied {new Date(app.applied_at).toLocaleDateString()} +

+
+
+ + {app.job_url && ( + + View โ†— + + )} +
+
+ ))} +
+ )} +
+ )} + + {/* Profile Tab */} + {tab === "profile" && ( +
+

My Profile

+
+
+
+
+ + setProfile({...profile, name: e.target.value})} + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" /> +
+
+ + setProfile({...profile, title: e.target.value})} + placeholder="e.g. Senior Full-Stack Developer" + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" /> +
+
+ +
+ +