Add plan system: Free(5) Starter(50) Pro(100) Ultra(200) Business(500) Unlimited(99999)

This commit is contained in:
2026-04-13 19:34:00 +02:00
parent b4658b6694
commit 7ba8b47546
7 changed files with 589 additions and 276 deletions
+409 -230
View File
@@ -1,17 +1,14 @@
"""
AutoJobs API — FastAPI Backend
Multi-user job search with per-user API keys
Multi-user job search with per-user API keys + subscription plans
"""
from fastapi import FastAPI, HTTPException, Depends
from fastapi import FastAPI, HTTPException, Depends, APIRouter
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 datetime import datetime, timedelta
import os, json, sqlite3, uuid
from contextlib import asynccontextmanager
DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db")
@@ -19,20 +16,38 @@ DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db")
# Router with /autojobs/api prefix
router = APIRouter(prefix="/autojobs/api")
# --- Plans & Pricing ---
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},
"business": {"name": "Business", "applications": 500, "ai_customize": 500, "price": 119},
"unlimited": {"name": "Unlimited", "applications": 99999, "ai_customize": 99999, "price": 319},
}
# --- Database ---
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Users with plan info
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
plan TEXT DEFAULT 'free',
api_keys TEXT DEFAULT '{}',
profile TEXT DEFAULT '{}',
monthly_count INTEGER DEFAULT 0,
ai_customize_count INTEGER DEFAULT 0,
billing_cycle_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Applications tracking
c.execute("""
CREATE TABLE IF NOT EXISTS applications (
id TEXT PRIMARY KEY,
@@ -48,6 +63,8 @@ def init_db():
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# API usage tracking
c.execute("""
CREATE TABLE IF NOT EXISTS api_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -58,6 +75,7 @@ def init_db():
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
conn.commit()
conn.close()
@@ -66,7 +84,7 @@ async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="AutoJobs API", version="1.0.0", lifespan=lifespan)
app = FastAPI(title="AutoJobs API", version="1.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -80,13 +98,60 @@ app.include_router(router)
security = HTTPBearer(auto_error=False)
# --- Simple Auth (API Key in header) ---
# --- Helper Functions ---
def get_user_row(user_id: str):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = c.fetchone()
conn.close()
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}"""
plan_name = user.get("plan", "free")
plan = PLANS.get(plan_name, PLANS["free"])
if action == "application":
used = user.get("monthly_count", 0)
limit = plan["applications"]
elif action == "ai_customize":
used = user.get("ai_customize_count", 0)
limit = plan["ai_customize"]
else:
return {"allowed": True, "remaining": 99999}
remaining = max(0, limit - used)
return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used}
def increment_usage(user_id: str, action: str):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if action == "application":
c.execute("UPDATE users SET monthly_count = monthly_count + 1 WHERE id = ?", (user_id,))
elif action == "ai_customize":
c.execute("UPDATE users SET ai_customize_count = ai_customize_count + 1 WHERE id = ?", (user_id,))
conn.commit()
conn.close()
def reset_monthly_counts():
"""Reset counts at the start of a new billing cycle (run daily via cron)"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
UPDATE users SET monthly_count = 0, ai_customize_count = 0,
billing_cycle_start = CURRENT_TIMESTAMP
WHERE date('now') >= date(billing_cycle_start, '+30 days')
""")
conn.commit()
conn.close()
# --- Auth ---
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"}
return {"id": credentials.credentials}
# --- Models ---
class UserProfile(BaseModel):
@@ -117,338 +182,434 @@ class ApplicationSubmit(BaseModel):
cover_letter: str
status: str = "applied"
class PlanUpdate(BaseModel):
plan: str
# --- Routes ---
@router.get("/")
@app.get("/")
def root():
return {"status": "ok", "service": "AutoJobs API", "version": "1.0.0"}
return {"status": "ok", "service": "AutoJobs API", "version": "1.1.0"}
@router.get("/health")
@app.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"""
def create_user(email: str, name: str = ""):
"""Create new user with free plan"""
user_id = str(uuid.uuid4())
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()
try:
c.execute("""
INSERT INTO users (id, email, name, plan, monthly_count, ai_customize_count)
VALUES (?, ?, ?, 'free', 0, 0)
""", (user_id, email, name))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="Email already registered")
conn.close()
return {"status": "ok", "user_id": user_id}
return {"user_id": user_id, "email": email, "plan": "free"}
@router.get("/users/me")
def get_me(user=Depends(get_current_user)):
@router.post("/users/login")
def login(email: str):
"""Login by email — returns user_id as token (MVP auth)"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM users WHERE id = ?", (user["id"],))
c.execute("SELECT id, email, name, plan FROM users WHERE email = ?", (email,))
row = c.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="User not found")
return dict(row)
return {"user_id": row["id"], "email": row["email"], "name": row["name"], "plan": row["plan"]}
@router.get("/users/me")
def get_me(user=Depends(get_current_user)):
"""Get current user info with plan details"""
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"])
return {
"id": user_row["id"],
"email": user_row["email"],
"name": user_row["name"],
"plan": user_row["plan"],
"plan_details": {
"name": plan["name"],
"applications_limit": plan["applications"],
"ai_customize_limit": plan["ai_customize"],
"price": plan["price"]
},
"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"])
},
"billing_cycle_start": user_row["billing_cycle_start"],
"created_at": user_row["created_at"]
}
@router.post("/users/me/plan")
def update_plan(plan_update: PlanUpdate, user=Depends(get_current_user)):
"""Update user's subscription plan"""
if plan_update.plan not in PLANS:
raise HTTPException(status_code=400, detail="Invalid plan")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("UPDATE users SET plan = ? WHERE id = ?", (plan_update.plan, user["id"]))
conn.commit()
conn.close()
return {"plan": plan_update.plan, "price": PLANS[plan_update.plan]["price"]}
@router.post("/users/me/api-keys")
def save_api_keys(keys: APIKeys, user=Depends(get_current_user)):
"""Save user's API keys"""
"""Save user's own 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"]))
c.execute("UPDATE users SET api_keys = ? WHERE id = ?", (keys.json(), user["id"]))
conn.commit()
conn.close()
return {"status": "ok"}
return {"saved": True}
@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 "{}")
"""Get user's stored API keys (masked for security)"""
user_row = get_user_row(user["id"])
if not user_row:
raise HTTPException(status_code=404, detail="User not found")
keys = json.loads(user_row.get("api_keys", "{}"))
# Mask keys for display
masked = {}
for k, v in keys.items():
if v and len(v) > 8:
masked[k] = v[:4] + "..." + v[-4:]
else:
masked[k] = v
return masked
@router.post("/users/me/profile")
def save_profile(profile: UserProfile, user=Depends(get_current_user)):
"""Save user's profile"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("UPDATE users SET profile = ? WHERE id = ?",
(json.dumps(profile.model_dump()), user["id"]))
c.execute("UPDATE users SET profile = ? WHERE id = ?", (profile.json(), user["id"]))
conn.commit()
conn.close()
return {"status": "ok"}
return {"saved": True}
@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"])
"""Get user's profile"""
user_row = get_user_row(user["id"])
if not user_row:
raise HTTPException(status_code=404, detail="User not found")
profile = json.loads(user_row.get("profile", "{}"))
return profile
@router.get("/plans")
def list_plans():
"""List all available plans"""
return {"plans": PLANS}
# --- 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:
"""Search for jobs using user's API keys"""
user_row = get_user_row(user["id"])
if not user_row:
raise HTTPException(status_code=404, detail="User not found")
keys = json.loads(user_row.get("api_keys", "{}"))
results = []
# Search Jooble
if keys.get("jooble"):
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", "")
})
jooble_results = search_jooble(req.query, req.location, keys["jooble"], req.page)
results.extend(jooble_results)
except Exception as e:
print(f"Jooble error: {e}")
# JSearch (RapidAPI)
if jsearch_key:
pass
# Search JSearch
if keys.get("jsearch"):
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', '')}"
})
jsearch_results = search_jsearch(req.query, req.location, keys["jsearch"], req.page)
results.extend(jsearch_results)
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
pass
# Deduplicate by job URL
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)}
for job in results:
if job.get("job_url") and job["job_url"] not in seen:
seen.add(job["job_url"])
unique.append(job)
# --- AI Customization ---
return {"jobs": unique, "total": len(unique)}
def search_jooble(query: str, location: str, api_key: str, page: int = 1) -> List[dict]:
"""Search Jooble API"""
import requests
url = "https://ukr-dev.com/api/v1/vacancies/search"
params = {"keywords": query, "location": location, "page": page, "results_per_page": 20}
headers = {"X-Api-Key": api_key}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 200:
data = resp.json()
jobs = []
for item in data.get("items", []):
jobs.append({
"title": item.get("title", ""),
"company": item.get("employer", {}).get("name", ""),
"location": item.get("area", {}).get("name", ""),
"job_url": item.get("link", ""),
"source": "jooble"
})
return jobs
except:
pass
return []
def search_jsearch(query: str, location: str, api_key: str, page: int = 1) -> List[dict]:
"""Search RapidAPI JSearch"""
import requests
url = "https://jsearch.p.rapidapi.com/search"
params = {"query": f"{query} {location}".strip(), "page": page, "num_pages": 1}
headers = {"X-RapidAPI-Key": api_key, "X-RapidAPI-Host": "jsearch.p.rapidapi.com"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 200:
data = resp.json()
jobs = []
for item in data.get("data", []):
jobs.append({
"title": item.get("job_title", ""),
"company": item.get("employer_name", ""),
"location": item.get("job_location", ""),
"job_url": item.get("job_apply_link", "") or item.get("job_google_link", ""),
"source": "jsearch"
})
return jobs
except:
pass
return []
# --- AI Customize ---
@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')}
"""AI customize resume + cover letter for a job"""
user_row = get_user_row(user["id"])
if not user_row:
raise HTTPException(status_code=404, detail="User not found")
## Summary
{user_profile.get('summary', '')}
# Check plan limit
check = check_plan_limit(user_row, "ai_customize")
if not check["allowed"]:
raise HTTPException(
status_code=402,
detail={
"error": "AI customization limit reached",
"plan": user_row["plan"],
"limit": check["limit"],
"used": check["used"],
"message": f"You've used all {check['used']} AI customizations for this billing cycle. Upgrade your plan for more."
}
)
## Key Skills (matched to job requirements)
{', '.join(relevant_skills) if relevant_skills else ', '.join(skills[:8])}
job_description = req.get("job_description", "")
resume_text = req.get("resume", "")
company_name = req.get("company", "")
job_title = req.get("title", "")
## 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,
keys = json.loads(user_row.get("api_keys", "{}"))
perplexity_key = keys.get("perplexity", "")
I am excited to apply for the {job_title} position at {company_name}.
if not perplexity_key:
raise HTTPException(status_code=400, detail="Perplexity API key required")
I bring {user_profile.get('summary', 'a strong background in software development and AI')} with key skills in {', '.join(skills[:5])}.
# Call Perplexity Sonar API
import requests
url = "https://api.perplexity.ai/chat/completions"
headers = {"Authorization": f"Bearer {perplexity_key}", "Content-Type": "application/json"}
payload = {
"model": "sonar",
"messages": [
{
"role": "system",
"content": "You are a professional resume writer and career consultant. Customize the resume and write a cover letter based on the job description."
},
{
"role": "user",
"content": f"""Job Title: {job_title}
Company: {company_name}
{"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"}.
Job Description:
{job_description}
I would welcome the opportunity to discuss how my background aligns with your needs.
My Resume:
{resume_text}
Best regards,
{user_profile.get('name', 'Applicant')}
"""
return {
"resume": tailored_resume,
"cover_letter": cover_letter,
"matched_skills": relevant_skills
Please provide:
1. A customized resume summary highlighting the most relevant skills and experience for this job
2. A tailored cover letter (200-300 words)
Format your response as:
## RESUME_CUSTOMIZATION
[your customized resume content here]
## COVER_LETTER
[your cover letter here]"""
}
]
}
# --- Applications ---
try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code == 200:
result = resp.json()
content = result["choices"][0]["message"]["content"]
# Parse response
parts = content.split("## COVER_LETTER")
resume_custom = parts[0].replace("## RESUME_CUSTOMIZATION", "").strip()
cover_letter = parts[1].strip() if len(parts) > 1 else ""
# Increment usage
increment_usage(user["id"], "ai_customize")
return {
"resume_customization": resume_custom,
"cover_letter": cover_letter
}
else:
raise HTTPException(status_code=502, detail="AI service error")
except requests.exceptions.Timeout:
raise HTTPException(status_code=504, detail="AI request timed out")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# --- Applications ---
@router.post("/applications")
def submit_application(app_data: ApplicationSubmit, user=Depends(get_current_user)):
import uuid
app_id = str(uuid.uuid4())[:8]
"""Submit a job application (tracks usage against plan limit)"""
user_row = get_user_row(user["id"])
if not user_row:
raise HTTPException(status_code=404, detail="User not found")
# Check plan limit
check = check_plan_limit(user_row, "application")
if not check["allowed"]:
raise HTTPException(
status_code=402,
detail={
"error": "Application limit reached",
"plan": user_row["plan"],
"limit": check["limit"],
"used": check["used"],
"message": f"You've applied to {check['used']} jobs this billing cycle. Upgrade your plan for more applications."
}
)
# Create application record
app_id = str(uuid.uuid4())
job = app_data.job
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 (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO applications (id, user_id, job_title, company, location, job_url, status, resume_version, 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_id,
user["id"],
job.get("title", ""),
job.get("company", ""),
job.get("location", ""),
job.get("job_url", ""),
app_data.status,
app_data.cover_letter[:500] if app_data.cover_letter else ""
app_data.resume[:5000] if app_data.resume else "",
app_data.cover_letter[:5000] if app_data.cover_letter else ""
))
conn.commit()
conn.close()
return {"status": "ok", "application_id": app_id}
# Increment usage
increment_usage(user["id"], "application")
return {"id": app_id, "status": "submitted"}
@router.get("/applications")
def get_applications(user=Depends(get_current_user)):
def get_applications(user=Depends(get_current_user), status: str = None, limit: int = 50):
"""Get user's applications"""
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"],))
if status:
c.execute("SELECT * FROM applications WHERE user_id = ? AND status = ? ORDER BY applied_at DESC LIMIT ?",
(user["id"], status, limit))
else:
c.execute("SELECT * FROM applications WHERE user_id = ? ORDER BY applied_at DESC LIMIT ?",
(user["id"], limit))
rows = c.fetchall()
conn.close()
return [dict(r) for r in rows]
return {"applications": [dict(r) for r in rows]}
@router.patch("/applications/{app_id}")
def update_application(app_id: str, status: str, user=Depends(get_current_user)):
def update_application(app_id: str, status_update: dict, user=Depends(get_current_user)):
"""Update application status"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
new_status = status_update.get("status", "applied")
c.execute("UPDATE applications SET status = ? WHERE id = ? AND user_id = ?",
(status, app_id, user["id"]))
(new_status, app_id, user["id"]))
conn.commit()
conn.close()
return {"status": "ok"}
return {"updated": True, "status": new_status}
# --- Admin Routes ---
@router.get("/admin/stats")
def admin_stats():
"""Global platform statistics"""
"""Platform-wide 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
SELECT plan, COUNT(*) as count FROM users GROUP BY plan
""")
api_usage = [dict(r) for r in c.fetchall()]
plan_distribution = [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
WHERE company != '' 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,
"plan_distribution": plan_distribution,
"daily_applications": daily_applications,
"top_companies": top_companies
}
@@ -460,9 +621,8 @@ def admin_users():
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
SELECT u.id, u.email, u.name, u.plan, u.monthly_count, u.ai_customize_count,
COUNT(a.id) as applications
FROM users u
LEFT JOIN applications a ON u.id = a.user_id
GROUP BY u.id
@@ -470,20 +630,39 @@ def admin_users():
""")
rows = c.fetchall()
conn.close()
return [dict(r) for r in rows]
return {"users": [dict(r) for r in rows]}
@router.get("/admin/applications")
def admin_applications():
def admin_applications(limit: int = 100):
"""All applications"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT a.*, u.email, u.name
SELECT a.*, u.email as user_email
FROM applications a
JOIN users u ON a.user_id = u.id
ORDER BY a.applied_at DESC
""")
ORDER BY a.applied_at DESC LIMIT ?
""", (limit,))
rows = c.fetchall()
conn.close()
return [dict(r) for r in rows]
return {"applications": [dict(r) for r in rows]}
@router.post("/admin/reset-usage")
def admin_reset_usage(user_id: str = None):
"""Admin: Reset usage counts (monthly billing cycle reset)"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if user_id:
c.execute("UPDATE users SET monthly_count = 0, ai_customize_count = 0, billing_cycle_start = CURRENT_TIMESTAMP WHERE id = ?", (user_id,))
msg = f"Reset usage for user {user_id}"
else:
c.execute("UPDATE users SET monthly_count = 0, ai_customize_count = 0, billing_cycle_start = CURRENT_TIMESTAMP")
msg = "Reset all users"
conn.commit()
conn.close()
return {"success": True, "message": msg}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+125 -46
View File
@@ -1,5 +1,56 @@
import Link from "next/link"
const plans = [
{
name: "Free",
price: "$0",
period: "forever",
apps: "5",
ai: "5",
badge: "Start Here",
highlight: false,
features: ["5 AI job applications/month", "5 AI resume customizations/month", "Application tracker", "Add your own API keys", "Multi-source search"],
cta: "Get Started Free",
ctaStyle: "bg-white/10 hover:bg-white/20"
},
{
name: "Starter",
price: "$9",
period: "/month",
apps: "50",
ai: "50",
badge: "",
highlight: false,
features: ["50 AI job applications/month", "50 AI resume customizations/month", "Application tracker", "Add your own API keys", "Email notifications"],
cta: "Start Applying",
ctaStyle: "bg-blue-500 hover:bg-blue-600"
},
{
name: "Pro",
price: "$39",
period: "/month",
apps: "100",
ai: "100",
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", "Interview prep tips"],
cta: "Go Pro",
ctaStyle: "bg-blue-500 hover:bg-blue-600"
},
{
name: "Ultra",
price: "$69",
period: "/month",
apps: "200",
ai: "200",
badge: "",
highlight: false,
features: ["200 AI job applications/month", "200 AI resume customizations/month", "Priority processing", "Add your own API keys", "Email + SMS notifications"],
cta: "Go Ultra",
ctaStyle: "bg-slate-600 hover:bg-slate-500"
},
]
export default function LandingPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900">
@@ -37,16 +88,15 @@ export default function LandingPage() {
href="/autojobs/signup"
className="px-10 py-4 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-bold text-lg transition shadow-lg shadow-blue-500/25"
>
Start Free Beta Access
Start Free 5 Applications
</Link>
<Link
href="/autojobs/demo"
href="/autojobs/pricing"
className="px-10 py-4 bg-white/10 hover:bg-white/20 text-white rounded-xl font-semibold text-lg border border-white/20 transition"
>
See How It Works
View All Plans
</Link>
</div>
<p className="mt-6 text-slate-500 text-sm">No credit card required. Beta is free.</p>
</div>
</section>
@@ -60,7 +110,7 @@ export default function LandingPage() {
{
step: "01",
title: "Create Your Profile",
desc: "Upload your resume and LinkedIn. Tell us what jobs you want — keywords, location, salary range. Add your own API keys for job search engines."
desc: "Upload your resume. Tell us what jobs you want — keywords, location, salary range. Add your own API keys for job search engines."
},
{
step: "02",
@@ -69,8 +119,8 @@ export default function LandingPage() {
},
{
step: "03",
title: "Auto-Apply 24/7",
desc: "AI applies on your behalf where possible. You review and approve the rest. Dashboard tracks every application and its status."
title: "Apply & Track",
desc: "Apply with one click. Track every application status — applied, interviewing, offer, rejected. Land more interviews."
}
].map((item) => (
<div key={item.step} className="relative bg-slate-700/50 rounded-2xl p-8 border border-slate-600 hover:border-blue-500/50 transition">
@@ -95,8 +145,8 @@ export default function LandingPage() {
{ icon: "📊", title: "Application Tracker", desc: "Dashboard shows every application, status, interview invites" },
{ icon: "🔑", title: "Your Own API Keys", desc: "Add your own keys — you control your data and spending" },
{ icon: "⚡", title: "Apply While You Sleep", desc: "AI works 24/7. Wake up to new opportunities submitted" },
{ icon: "👥", title: "Multi-User", desc: "Separate accounts for you, your friends, your clients" },
{ icon: "📈", title: "Admin Dashboard", desc: "See all users, applications, and platform analytics" },
{ icon: "📱", title: "Mobile Dashboard", desc: "Track your applications from anywhere — phone, tablet, desktop" },
{ icon: "📈", title: "Usage Analytics", desc: "See how many applications you've sent this month" },
{ icon: "🔒", title: "Private & Secure", desc: "Your data stays yours. API keys are encrypted." },
].map((f) => (
<div key={f.title} className="bg-slate-800/50 rounded-xl p-5 border border-slate-700 hover:border-slate-500 transition">
@@ -111,43 +161,72 @@ export default function LandingPage() {
{/* Pricing */}
<section className="py-20 px-6 bg-slate-800/40">
<div className="max-w-3xl mx-auto text-center">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl font-bold text-white mb-4">Simple, Transparent Pricing</h2>
<p className="text-slate-400 mb-10">Start free during beta. Scale when you're landing interviews.</p>
<div className="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
<div className="bg-slate-700/60 rounded-2xl p-8 border border-slate-600">
<h3 className="text-xl font-semibold text-white mb-2">Beta</h3>
<div className="text-4xl font-bold text-white mb-1">Free</div>
<p className="text-slate-400 mb-6 text-sm">While we build</p>
<ul className="text-slate-300 text-left space-y-2 mb-8">
<li> Unlimited applications</li>
<li> AI resume tailoring</li>
<li> AI cover letters</li>
<li> Application tracking</li>
<li> Add your own API keys</li>
<li> Multi-user support</li>
</ul>
<Link href="/autojobs/signup" className="block text-center px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-lg font-medium transition">
Join Beta Free
</Link>
</div>
<div className="bg-gradient-to-br from-blue-600/20 to-purple-600/20 rounded-2xl p-8 border border-blue-500/30">
<div className="text-sm text-blue-400 font-medium mb-1">Coming Soon</div>
<h3 className="text-xl font-semibold text-white mb-2">Pro</h3>
<div className="text-4xl font-bold text-white mb-1">19<span className="text-lg font-normal text-slate-400">/mo</span></div>
<p className="text-slate-400 mb-6 text-sm">When we launch publicly</p>
<ul className="text-slate-300 text-left space-y-2 mb-8">
<li> Everything in Beta</li>
<li> We provide API keys</li>
<li> Auto-apply everywhere</li>
<li> Email notifications</li>
<li> Interview prep tips</li>
<li> Priority support</li>
</ul>
<button className="w-full px-6 py-3 bg-blue-500/50 text-white rounded-lg font-medium cursor-not-allowed" disabled>
Join Waitlist
</button>
</div>
<p className="text-slate-400 mb-12">Pick the plan that fits your job search. No hidden fees.</p>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto" style={{gridTemplateColumns: 'repeat(4, 1fr)'}}>
{plans.map((plan) => (
<div
key={plan.name}
className={`rounded-2xl p-6 border text-left relative ${
plan.highlight
? 'bg-gradient-to-br from-blue-600/30 to-purple-600/30 border-blue-500/50 shadow-lg shadow-blue-500/10'
: 'bg-slate-700/50 border-slate-600'
}`}
>
{plan.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 bg-blue-500 text-white text-xs font-bold rounded-full">
{plan.badge}
</span>
</div>
)}
<div className="text-sm text-slate-400 font-medium mb-1">{plan.name}</div>
<div className="flex items-baseline gap-1 mb-1">
<span className="text-3xl font-bold text-white">{plan.price}</span>
<span className="text-slate-400 text-sm">{plan.period}</span>
</div>
<div className="text-xs text-slate-500 mb-4">
{plan.apps} applications + {plan.ai} AI customizations/month
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((f) => (
<li key={f} className="text-slate-300 text-sm flex items-start gap-2">
<span className="text-green-400"></span> {f}
</li>
))}
</ul>
<Link
href="/autojobs/signup"
className={`block text-center px-4 py-2.5 rounded-lg font-medium text-white transition ${plan.ctaStyle}`}
>
{plan.cta}
</Link>
</div>
))}
</div>
<div className="mt-8 text-slate-400 text-sm">
<p>Need more? <span className="text-blue-400">Business</span> (500/mo) = $119 &nbsp;|&nbsp; <span className="text-blue-400">Unlimited</span> = $319/month</p>
</div>
</div>
</section>
{/* Stats */}
<section className="py-16 px-6">
<div className="max-w-4xl mx-auto grid grid-cols-3 gap-8 text-center">
<div>
<div className="text-3xl font-bold text-white mb-1">5</div>
<div className="text-slate-400 text-sm">Free applications/month</div>
</div>
<div>
<div className="text-3xl font-bold text-white mb-1">&lt;$0.01</div>
<div className="text-slate-400 text-sm">Cost per AI customization</div>
</div>
<div>
<div className="text-3xl font-bold text-white mb-1">15-25s</div>
<div className="text-slate-400 text-sm">AI time per application</div>
</div>
</div>
</section>
@@ -160,4 +239,4 @@ export default function LandingPage() {
</footer>
</div>
)
}
}
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
apps: [{
name: 'autojobs-frontend',
script: 'npx',
args: 'next start -p 3001',
cwd: '/var/www/autojobs/frontend',
interpreter: 'none',
env: {
NODE_ENV: 'production',
PORT: '3001'
},
autorestart: true,
watch: false
}]
};
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+8
View File
@@ -0,0 +1,8 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
theme: { extend: {} },
plugins: [],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}