Separate private and agency subscription plans, add LinkedIn OAuth integration

This commit is contained in:
2026-04-13 19:43:10 +02:00
parent 64465c3a67
commit da19bbac4b
2 changed files with 228 additions and 104 deletions
+168 -32
View File
@@ -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
}