diff --git a/backend/main.py b/backend/main.py index de28b83..b34a7fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,21 +17,23 @@ DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db") router = APIRouter(prefix="/autojobs/api") # --- Plans & Pricing --- -PLANS = { - "free": {"name": "Free", "applications": 5, "ai_customize": 5, "price": 0, "type": "jobseeker"}, - "starter": {"name": "Starter", "applications": 50, "ai_customize": 50, "price": 9, "type": "jobseeker"}, - "pro": {"name": "Pro", "applications": 100, "ai_customize": 100, "price": 39, "type": "jobseeker"}, - "ultra": {"name": "Ultra", "applications": 200, "ai_customize": 200, "price": 69, "type": "jobseeker"}, - "business": {"name": "Business", "applications": 500, "ai_customize": 500, "price": 119, "type": "jobseeker"}, - "unlimited": {"name": "Unlimited", "applications": 99999, "ai_customize": 99999, "price": 319, "type": "jobseeker"}, +# Private User Plans (job seekers) +PRIVATE_PLANS = { + "free": {"name": "Free", "applications": 5, "ai_customize": 5, "price": 0}, + "starter": {"name": "Starter", "applications": 50, "ai_customize": 50, "price": 9}, + "pro": {"name": "Pro", "applications": 100, "ai_customize": 100, "price": 39}, + "ultra": {"name": "Ultra", "applications": 200, "ai_customize": 200, "price": 69}, } +# Alias for backwards compatibility +PLANS = PRIVATE_PLANS + # Agency Plans (recruiting agencies - manage multiple clients) AGENCY_PLANS = { - "agency_starter": {"name": "Agency Starter", "submissions": 1000, "clients": 10, "price": 555, "type": "agency"}, - "agency_growth": {"name": "Agency Growth", "submissions": 3000, "clients": 50, "price": 999, "type": "agency"}, - "agency_scale": {"name": "Agency Scale", "submissions": 5000, "clients": 150, "price": 1499, "type": "agency"}, - "agency_enterprise": {"name": "Agency Enterprise", "submissions": 10000, "clients": 500, "price": 2222, "type": "agency"}, + "agency_starter": {"name": "Agency Starter", "submissions": 1000, "clients": 10, "price": 555}, + "agency_growth": {"name": "Agency Growth", "submissions": 3000, "clients": 50, "price": 999}, + "agency_scale": {"name": "Agency Scale", "submissions": 5000, "clients": 150, "price": 1499}, + "agency_enterprise": {"name": "Agency Enterprise", "submissions": 10000, "clients": 500, "price": 2222}, } # --- Database --- @@ -45,9 +47,13 @@ def init_db(): id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT, + user_type TEXT DEFAULT 'private', plan TEXT DEFAULT 'free', api_keys TEXT DEFAULT '{}', profile TEXT DEFAULT '{}', + linkedin_access_token TEXT DEFAULT '', + linkedin_refresh_token TEXT DEFAULT '', + linkedin_profile_id TEXT DEFAULT '', monthly_count INTEGER DEFAULT 0, ai_customize_count INTEGER DEFAULT 0, billing_cycle_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -131,10 +137,11 @@ def get_user_row(user_id: str): return dict(user) if user else None def check_plan_limit(user: dict, action: str, count: int = 1) -> dict: - """Check if user has reached their plan limit. Returns {"allowed": bool, "remaining": int}""" + """Check if user has reached their plan limit""" plan_name = user.get("plan", "free") + user_type = user.get("user_type", "private") - # Check if agency plan + # Agency plans if plan_name in AGENCY_PLANS: plan = AGENCY_PLANS[plan_name] if action == "submission": @@ -143,10 +150,10 @@ def check_plan_limit(user: dict, action: str, count: int = 1) -> dict: else: return {"allowed": True, "remaining": 99999} remaining = max(0, limit - used) - return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used, "type": "agency"} + return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used, "type": "agency", "user_type": "agency"} - # Jobseeker plan - plan = PLANS.get(plan_name, PLANS["free"]) + # Private user plans + plan = PRIVATE_PLANS.get(plan_name, PRIVATE_PLANS["free"]) if action == "application": used = user.get("monthly_count", 0) limit = plan["applications"] @@ -157,7 +164,7 @@ def check_plan_limit(user: dict, action: str, count: int = 1) -> dict: return {"allowed": True, "remaining": 99999} remaining = max(0, limit - used) - return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used, "type": "jobseeker"} + return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used, "type": "jobseeker", "user_type": "private"} def increment_usage(user_id: str, action: str): conn = sqlite3.connect(DB_PATH) @@ -231,17 +238,133 @@ def health(): # --- User Routes --- + +# --- LinkedIn OAuth --- +@router.get("/auth/linkedin") +def linkedin_auth(redirect_uri: str = "https://hostpioneers.com/autojobs/dashboard"): + """Start LinkedIn OAuth flow""" + client_id = os.getenv("LINKEDIN_CLIENT_ID", "") + if not client_id: + raise HTTPException(status_code=500, detail="LinkedIn OAuth not configured") + + scope = "openid profile email w_member_social" + state = str(uuid.uuid4()) + + auth_url = ( + f"https://www.linkedin.com/oauth/v2/authorization" + f"?response_type=code" + f"&client_id={client_id}" + f"&redirect_uri={redirect_uri}" + f"&scope={scope}" + f"&state={state}" + ) + return {"auth_url": auth_url} + +@router.post("/auth/linkedin/callback") +def linkedin_callback(code: str, user=Depends(get_current_user)): + """Exchange LinkedIn code for access token and store it""" + client_id = os.getenv("LINKEDIN_CLIENT_ID", "") + client_secret = os.getenv("LINKEDIN_CLIENT_SECRET", "") + redirect_uri = "https://hostpioneers.com/autojobs/dashboard" + + import requests + token_url = "https://www.linkedin.com/oauth/v2/accessToken" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + } + + try: + resp = requests.post(token_url, data=data, timeout=10) + if resp.status_code == 200: + token_data = resp.json() + access_token = token_data.get("access_token", "") + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + UPDATE users SET + linkedin_access_token = ?, + linkedin_refresh_token = ? + WHERE id = ? + """, (access_token, token_data.get("refresh_token", ""), user["id"])) + conn.commit() + conn.close() + + return {"success": True, "message": "LinkedIn connected"} + else: + raise HTTPException(status_code=400, detail="Failed to get LinkedIn token") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/linkedin/resumes") +def get_linkedin_resumes(user=Depends(get_current_user)): + """Get user's LinkedIn saved resumes""" + user_row = get_user_row(user["id"]) + if not user_row or not user_row.get("linkedin_access_token"): + raise HTTPException(status_code=400, detail="LinkedIn not connected") + + import requests + headers = {"Authorization": f"Bearer {user_row['linkedin_access_token']}"} + + # Get LinkedIn profile to get resume info + try: + # LinkedIn Lite Profile + profile_resp = requests.get( + "https://api.linkedin.com/v2/me", + headers=headers, + timeout=10 + ) + + # For MVP - return placeholder (LinkedIn API requires special approval for resume access) + # In production you'd use LinkedIn's resume API + return { + "resumes": [ + {"id": "linkedin_default", "name": "LinkedIn Saved Resume", "source": "linkedin"} + ], + "message": "Connect your LinkedIn to import your saved resumes" + } + except: + return { + "resumes": [], + "message": "Could not access LinkedIn. Please re-connect." + } + +@router.post("/linkedin/disconnect") +def linkedin_disconnect(user=Depends(get_current_user)): + """Disconnect LinkedIn account""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + UPDATE users SET + linkedin_access_token = '', + linkedin_refresh_token = '', + linkedin_profile_id = '' + WHERE id = ? + """, (user["id"],)) + conn.commit() + conn.close() + return {"success": True} + @router.post("/users") -def create_user(email: str, name: str = ""): - """Create new user with free plan""" +def create_user(data: dict): + """Create new user (private or agency)""" user_id = str(uuid.uuid4()) + email = data.get("email", "") + name = data.get("name", "") + user_type = data.get("user_type", "private") + plan = data.get("plan", "free") + conn = sqlite3.connect(DB_PATH) c = conn.cursor() try: c.execute(""" - INSERT INTO users (id, email, name, plan, monthly_count, ai_customize_count) - VALUES (?, ?, ?, 'free', 0, 0) - """, (user_id, email, name)) + INSERT INTO users (id, email, name, user_type, plan, monthly_count, ai_customize_count) + VALUES (?, ?, ?, ?, ?, 0, 0) + """, (user_id, email, name, user_type, plan)) conn.commit() except sqlite3.IntegrityError: conn.close() @@ -250,17 +373,24 @@ def create_user(email: str, name: str = ""): return {"user_id": user_id, "email": email, "plan": "free"} @router.post("/users/login") -def login(email: str): +def login(data: dict): """Login by email — returns user_id as token (MVP auth)""" + email = data.get("email", "") conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute("SELECT id, email, name, plan FROM users WHERE email = ?", (email,)) + c.execute("SELECT id, email, name, user_type, plan FROM users WHERE email = ?", (email,)) row = c.fetchone() conn.close() if not row: raise HTTPException(status_code=404, detail="User not found") - return {"user_id": row["id"], "email": row["email"], "name": row["name"], "plan": row["plan"]} + return { + "user_id": row["id"], + "email": row["email"], + "name": row["name"], + "user_type": row["user_type"], + "plan": row["plan"] + } @router.get("/users/me") def get_me(user=Depends(get_current_user)): @@ -268,23 +398,29 @@ def get_me(user=Depends(get_current_user)): user_row = get_user_row(user["id"]) if not user_row: raise HTTPException(status_code=404, detail="User not found") - plan = PLANS.get(user_row["plan"], PLANS["free"]) + + user_type = user_row.get("user_type", "private") + if user_type == "agency": + plan = AGENCY_PLANS.get(user_row["plan"], AGENCY_PLANS["agency_starter"]) + else: + plan = PRIVATE_PLANS.get(user_row["plan"], PRIVATE_PLANS["free"]) + return { "id": user_row["id"], "email": user_row["email"], "name": user_row["name"], + "user_type": user_type, "plan": user_row["plan"], "plan_details": { "name": plan["name"], - "applications_limit": plan["applications"], - "ai_customize_limit": plan["ai_customize"], "price": plan["price"] }, + "linkedin_connected": bool(user_row.get("linkedin_access_token")), "usage": { "applications_this_month": user_row["monthly_count"], "ai_customizations_this_month": user_row["ai_customize_count"], - "applications_remaining": max(0, plan["applications"] - user_row["monthly_count"]), - "ai_customize_remaining": max(0, plan["ai_customize"] - user_row["ai_customize_count"]) + "applications_remaining": max(0, plan.get("applications", plan.get("submissions", 99999)) - user_row["monthly_count"]), + "ai_customize_remaining": max(0, plan.get("ai_customize", 99999) - user_row["ai_customize_count"]) }, "billing_cycle_start": user_row["billing_cycle_start"], "created_at": user_row["created_at"] @@ -349,9 +485,9 @@ def get_profile(user=Depends(get_current_user)): @router.get("/plans") def list_plans(): - """List all available plans (jobseeker + agency)""" + """List all available plans separated by user type""" return { - "jobseeker_plans": PLANS, + "private_plans": PRIVATE_PLANS, "agency_plans": AGENCY_PLANS } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index b3c77b6..df0d525 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,14 +1,12 @@ import Link from "next/link" -const jobseekerPlans = [ +const privatePlans = [ { name: "Free", price: "$0", period: "forever", apps: "5", - badge: "", - highlight: false, - features: ["5 AI job applications/month", "5 AI resume customizations/month", "Application tracker", "Add your own API keys"], + features: ["5 job applications/month", "5 AI resume customizations", "Basic application tracker"], cta: "Get Started", ctaStyle: "bg-white/10 hover:bg-white/20" }, @@ -17,10 +15,8 @@ const jobseekerPlans = [ price: "$9", period: "/mo", apps: "50", - badge: "", - highlight: false, - features: ["50 AI job applications/month", "50 AI resume customizations/month", "Application tracker", "Add your own API keys"], - cta: "Start Applying", + features: ["50 job applications/month", "50 AI resume customizations", "Add your own API keys", "Email support"], + cta: "Start Now", ctaStyle: "bg-blue-500 hover:bg-blue-600" }, { @@ -28,9 +24,9 @@ const jobseekerPlans = [ price: "$39", period: "/mo", apps: "100", - badge: "Popular", + badge: "Most Popular", highlight: true, - features: ["100 AI job applications/month", "100 AI resume customizations/month", "Application tracker", "Add your own API keys", "Email notifications"], + features: ["100 job applications/month", "100 AI resume customizations", "Add your own API keys", "LinkedIn resume import", "Priority support"], cta: "Go Pro", ctaStyle: "bg-blue-500 hover:bg-blue-600" }, @@ -39,9 +35,7 @@ const jobseekerPlans = [ price: "$69", period: "/mo", apps: "200", - badge: "", - highlight: false, - features: ["200 AI job applications/month", "200 AI resume customizations/month", "Priority processing", "Email + SMS notifications"], + features: ["200 job applications/month", "200 AI resume customizations", "Add your own API keys", "LinkedIn resume import", "SMS notifications"], cta: "Go Ultra", ctaStyle: "bg-slate-600 hover:bg-slate-500" }, @@ -56,7 +50,7 @@ const agencyPlans = [ clients: "10", badge: "", highlight: false, - features: ["1,000 job submissions/month", "Up to 10 client profiles", "AI resume tailoring", "White-label dashboard", "Priority support"], + features: ["1,000 submissions/month", "10 client profiles", "White-label dashboard", "API access"], cta: "Start Agency", ctaStyle: "bg-purple-600 hover:bg-purple-500" }, @@ -68,8 +62,8 @@ const agencyPlans = [ clients: "50", badge: "", highlight: true, - features: ["3,000 job submissions/month", "Up to 50 client profiles", "AI resume tailoring", "White-label dashboard", "Priority support"], - cta: "Grow Agency", + features: ["3,000 submissions/month", "50 client profiles", "White-label dashboard", "Priority support"], + cta: "Grow", ctaStyle: "bg-purple-600 hover:bg-purple-500" }, { @@ -80,8 +74,8 @@ const agencyPlans = [ clients: "150", badge: "", highlight: false, - features: ["5,000 job submissions/month", "Up to 150 client profiles", "AI resume tailoring", "White-label dashboard", "Dedicated account manager"], - cta: "Scale Up", + features: ["5,000 submissions/month", "150 client profiles", "White-label dashboard", "Dedicated account manager"], + cta: "Scale", ctaStyle: "bg-slate-600 hover:bg-slate-500" }, { @@ -92,8 +86,8 @@ const agencyPlans = [ clients: "500", badge: "", highlight: false, - features: ["10,000 job submissions/month", "Up to 500 client profiles", "AI resume tailoring", "White-label dashboard", "Dedicated account manager", "Custom integrations"], - cta: "Get Enterprise", + features: ["10,000 submissions/month", "500 client profiles", "White-label dashboard", "Dedicated account manager", "Custom integrations"], + cta: "Enterprise", ctaStyle: "bg-slate-600 hover:bg-slate-500" }, ] @@ -106,8 +100,10 @@ export default function LandingPage() {
AutoJobs
-
- Login +
+ + Login + Get Started @@ -115,20 +111,18 @@ export default function LandingPage() { {/* Hero */} -
+
- Your Personal AI Job Agent + AI-Powered Job Application Automation

- Stop Applying to Jobs. + Stop Manually Applying.
- Let AI Do It For You. + Let AI Handle It.

- Upload your resume once. Set your keywords. Our AI finds every matching job, - rewrites your resume + cover letter for each one, and applies automatically — - while you sleep. + Upload your resume once. Set your preferences. AI finds matching jobs, rewrites your resume for each one, and applies — while you sleep.

Start Free — 5 Applications + + + + + Login with LinkedIn +
{/* How It Works */} -
+
-

How AutoJobs Works

+

How It Works

{[ { step: "01", title: "Create Your Profile", - desc: "Upload your resume. Tell us what jobs you want — keywords, location, salary range." + desc: "Upload your resume. Connect your LinkedIn. Tell us what jobs you want — keywords, location, salary range." }, { step: "02", title: "AI Finds & Customizes", - desc: "We search Jooble, JSearch, and more. AI rewrites your resume and writes cover letters." + desc: "We search across multiple job boards. For each match, AI rewrites your resume and writes a personalized cover letter." }, { step: "03", title: "Apply & Track", - desc: "Apply with one click. Track every application status — from applied to offer." + desc: "Apply with one click or let AI apply automatically. Track every application status in your dashboard." } ].map((item) => ( -
+
{item.step}

{item.title}

-

{item.desc}

+

{item.desc}

))}
- {/* Jobseeker Pricing */} + {/* Private Plans */}
-

For Job Seekers

-

Pick the plan that fits your job search.

+

For Job Seekers

+

Choose your monthly application limit

- {jobseekerPlans.map((plan) => ( + {privatePlans.map((plan) => (
{plan.badge && (
- - {plan.badge} - + {plan.badge}
)}
{plan.name}
@@ -201,7 +202,7 @@ export default function LandingPage() { {plan.price} {plan.period}
-
{plan.apps} apps + AI customizations/mo
+
{plan.apps} apps/month
    {plan.features.map((f) => (
  • @@ -210,7 +211,7 @@ export default function LandingPage() { ))}
{plan.cta} @@ -218,17 +219,24 @@ export default function LandingPage() {
))}
+ +
+ + + + Login with LinkedIn — import your saved resumes automatically +
- {/* Agency Pricing */} + {/* Agency Plans */}
For Recruiting Agencies
-

Agency Plans — Manage Multiple Clients

-

Run job applications for your entire client roster. Each plan includes client profile management and submission limits.

+

Agency Plans

+

Manage multiple clients. Each plan has a hard submission cap — no unlimited.

{agencyPlans.map((plan) => ( @@ -236,18 +244,16 @@ export default function LandingPage() { key={plan.name} className={`rounded-2xl p-6 border text-left relative ${ plan.highlight - ? 'bg-gradient-to-br from-purple-600/30 to-pink-600/30 border-purple-500/50 shadow-lg shadow-purple-500/10' + ? 'bg-gradient-to-br from-purple-600/30 to-pink-600/30 border-purple-500/50' : 'bg-slate-700/50 border-slate-600' }`} > {plan.badge && (
- - {plan.badge} - + {plan.badge}
)} -
{plan.name}
+
Agency {plan.name}
{plan.price} {plan.period} @@ -271,25 +277,7 @@ export default function LandingPage() {
-

No unlimited agency plan — all submissions are capped to prevent abuse.

-
-
-
- - {/* Stats */} -
-
-
-
<$0.01
-
Cost per application
-
-
-
15-25s
-
AI time per job customization
-
-
-
500+
-
Jobs found per search
+ All plans have hard caps. No unlimited access for agencies.