feat: add design system with colors, components, and theme configuration
- Introduced a comprehensive color palette in colors.ts, including primary, accent, success, warning, error, info, neutral, slate, and semantic colors for light and dark modes. - Created a components-registry.ts to manage UI components with metadata, including buttons, inputs, cards, layout, feedback, and navigation components. - Developed a theme.ts file to centralize typography, spacing, border radius, shadows, z-index, transitions, breakpoints, containers, and responsive utilities. - Configured Nginx for development with a new nginx.dev.conf, routing API requests to the Flask backend and frontend requests to the Vite development server.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Authenticated account endpoints — usage summary and API key management."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app.extensions import limiter
|
||||
@@ -9,7 +10,15 @@ from app.services.account_service import (
|
||||
revoke_api_key,
|
||||
)
|
||||
from app.services.policy_service import get_usage_summary_for_user
|
||||
from app.services.stripe_service import (
|
||||
is_stripe_configured,
|
||||
get_stripe_price_id,
|
||||
)
|
||||
from app.utils.auth import get_current_user_id
|
||||
import stripe
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
account_bp = Blueprint("account", __name__)
|
||||
|
||||
@@ -29,6 +38,69 @@ def get_usage_route():
|
||||
return jsonify(get_usage_summary_for_user(user_id, user["plan"])), 200
|
||||
|
||||
|
||||
@account_bp.route("/subscription", methods=["GET"])
|
||||
@limiter.limit("60/hour")
|
||||
def get_subscription_status():
|
||||
"""Return subscription status for the authenticated user."""
|
||||
user_id = get_current_user_id()
|
||||
if user_id is None:
|
||||
return jsonify({"error": "Authentication required."}), 401
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
|
||||
# If Stripe is not configured, return basic info
|
||||
if not is_stripe_configured():
|
||||
return jsonify(
|
||||
{
|
||||
"plan": user["plan"],
|
||||
"stripe_configured": False,
|
||||
"subscription": None,
|
||||
}
|
||||
), 200
|
||||
|
||||
# Retrieve subscription info from Stripe if available
|
||||
subscription_info = None
|
||||
if user.get("stripe_subscription_id"):
|
||||
try:
|
||||
from app.services.stripe_service import get_stripe_secret_key
|
||||
|
||||
stripe.api_key = get_stripe_secret_key()
|
||||
|
||||
subscription = stripe.Subscription.retrieve(user["stripe_subscription_id"])
|
||||
subscription_info = {
|
||||
"id": subscription.id,
|
||||
"status": subscription.status,
|
||||
"current_period_start": subscription.current_period_start,
|
||||
"current_period_end": subscription.current_period_end,
|
||||
"cancel_at_period_end": subscription.cancel_at_period_end,
|
||||
"items": [
|
||||
{
|
||||
"price": item.price.id,
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
for item in subscription.items.data
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to retrieve subscription {user['stripe_subscription_id']}: {e}"
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"plan": user["plan"],
|
||||
"stripe_configured": True,
|
||||
"subscription": subscription_info,
|
||||
"pricing": {
|
||||
"monthly_price_id": get_stripe_price_id("monthly"),
|
||||
"yearly_price_id": get_stripe_price_id("yearly"),
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@account_bp.route("/api-keys", methods=["GET"])
|
||||
@limiter.limit("60/hour")
|
||||
def list_api_keys_route():
|
||||
|
||||
460
backend/app/services/quota_service.py
Normal file
460
backend/app/services/quota_service.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
QuotaService
|
||||
Manages usage quotas and limits for Free, Pro, and Business tiers
|
||||
|
||||
Quota Structure:
|
||||
- Free: 5 conversions/day, 10MB max file size, no batch processing
|
||||
- Pro: 100 conversions/day, 100MB max file size, batch processing (5 files)
|
||||
- Business: Unlimited conversions, 500MB max file size, batch processing (20 files)
|
||||
|
||||
Tracks:
|
||||
- Daily usage (resets at UTC midnight)
|
||||
- Storage usage
|
||||
- API rate limits
|
||||
- Feature access (premium features)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Tuple
|
||||
from flask import current_app
|
||||
from app.services.account_service import _connect, _utc_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuotaLimits:
|
||||
"""Define quota limits for each tier"""
|
||||
|
||||
# Conversions per day
|
||||
CONVERSIONS_PER_DAY = {
|
||||
"free": 5,
|
||||
"pro": 100,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# Maximum file size in MB
|
||||
MAX_FILE_SIZE_MB = {
|
||||
"free": 10,
|
||||
"pro": 100,
|
||||
"business": 500,
|
||||
}
|
||||
|
||||
# Storage limit in MB (monthly)
|
||||
STORAGE_LIMIT_MB = {
|
||||
"free": 500,
|
||||
"pro": 5000,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# API rate limit (requests per minute)
|
||||
API_RATE_LIMIT = {
|
||||
"free": 10,
|
||||
"pro": 60,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# Concurrent processing jobs
|
||||
CONCURRENT_JOBS = {
|
||||
"free": 1,
|
||||
"pro": 3,
|
||||
"business": 10,
|
||||
}
|
||||
|
||||
# Batch file limit
|
||||
BATCH_FILE_LIMIT = {
|
||||
"free": 1,
|
||||
"pro": 5,
|
||||
"business": 20,
|
||||
}
|
||||
|
||||
# Premium features (Pro/Business)
|
||||
PREMIUM_FEATURES = {
|
||||
"free": set(),
|
||||
"pro": {"batch_processing", "priority_queue", "email_delivery", "api_access"},
|
||||
"business": {
|
||||
"batch_processing",
|
||||
"priority_queue",
|
||||
"email_delivery",
|
||||
"api_access",
|
||||
"webhook",
|
||||
"sso",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class QuotaService:
|
||||
"""Service for managing user quotas and usage tracking"""
|
||||
|
||||
@staticmethod
|
||||
def _ensure_quota_tables():
|
||||
"""Create quota tracking tables if they don't exist"""
|
||||
conn = _connect()
|
||||
try:
|
||||
# Daily usage tracking
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
conversions INTEGER DEFAULT 0,
|
||||
files_processed INTEGER DEFAULT 0,
|
||||
total_size_mb REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, date),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Storage usage tracking
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS storage_usage (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
month TEXT NOT NULL,
|
||||
total_size_mb REAL DEFAULT 0,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, month),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# API rate limiting
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_requests (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Feature access log
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feature_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
feature TEXT NOT NULL,
|
||||
accessed_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
allowed BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Quota tables initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create quota tables: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def init_quota_db():
|
||||
"""Initialize quota tracking database"""
|
||||
QuotaService._ensure_quota_tables()
|
||||
|
||||
@staticmethod
|
||||
def get_user_plan(user_id: int) -> str:
|
||||
"""Get user's current plan"""
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT plan FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return row["plan"] if row else "free"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def get_daily_usage(user_id: int, date: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Get daily usage for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
date: Date in YYYY-MM-DD format (defaults to today)
|
||||
|
||||
Returns:
|
||||
Usage stats dict
|
||||
"""
|
||||
if not date:
|
||||
date = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, date),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"conversions": 0,
|
||||
"files_processed": 0,
|
||||
"total_size_mb": 0,
|
||||
}
|
||||
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def record_conversion(user_id: int, file_size_mb: float) -> bool:
|
||||
"""
|
||||
Record a file conversion
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
file_size_mb: File size in MB
|
||||
|
||||
Returns:
|
||||
True if allowed, False if quota exceeded
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# Check daily conversion limit
|
||||
usage = conn.execute(
|
||||
"SELECT conversions FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, today),
|
||||
).fetchone()
|
||||
|
||||
current_conversions = usage["conversions"] if usage else 0
|
||||
limit = QuotaLimits.CONVERSIONS_PER_DAY[plan]
|
||||
|
||||
if current_conversions >= limit and limit != float("inf"):
|
||||
logger.warning(f"User {user_id} exceeded daily conversion limit")
|
||||
return False
|
||||
|
||||
# Check file size limit
|
||||
max_size = QuotaLimits.MAX_FILE_SIZE_MB[plan]
|
||||
if file_size_mb > max_size:
|
||||
logger.warning(
|
||||
f"User {user_id} file exceeds size limit: {file_size_mb}MB > {max_size}MB"
|
||||
)
|
||||
return False
|
||||
|
||||
# Record the conversion
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO daily_usage (user_id, date, conversions, files_processed, total_size_mb)
|
||||
VALUES (?, ?, 1, 1, ?)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET
|
||||
conversions = conversions + 1,
|
||||
files_processed = files_processed + 1,
|
||||
total_size_mb = total_size_mb + ?
|
||||
""",
|
||||
(user_id, today, file_size_mb, file_size_mb),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Recorded conversion for user {user_id}: {file_size_mb}MB")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record conversion: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def check_rate_limit(user_id: int) -> Tuple[bool, int]:
|
||||
"""
|
||||
Check if user has exceeded API rate limit
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
(allowed, remaining_requests_in_window) tuple
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
limit = QuotaLimits.API_RATE_LIMIT[plan]
|
||||
|
||||
if limit == float("inf"):
|
||||
return True, -1 # Unlimited
|
||||
|
||||
# Check requests in last minute
|
||||
one_minute_ago = (datetime.utcnow() - timedelta(minutes=1)).isoformat()
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) as count FROM api_requests WHERE user_id = ? AND timestamp > ?",
|
||||
(user_id, one_minute_ago),
|
||||
).fetchone()["count"]
|
||||
|
||||
if count >= limit:
|
||||
return False, 0
|
||||
|
||||
# Record this request
|
||||
conn.execute(
|
||||
"INSERT INTO api_requests (user_id, endpoint) VALUES (?, ?)",
|
||||
(user_id, "api"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return True, limit - count - 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def has_feature(user_id: int, feature: str) -> bool:
|
||||
"""
|
||||
Check if user has access to a premium feature
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
feature: Feature name (e.g., 'batch_processing')
|
||||
|
||||
Returns:
|
||||
True if user can access feature
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
allowed = feature in QuotaLimits.PREMIUM_FEATURES[plan]
|
||||
|
||||
# Log feature access attempt
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO feature_access (user_id, feature, allowed) VALUES (?, ?, ?)",
|
||||
(user_id, feature, allowed),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log feature access: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def get_quota_status(user_id: int) -> Dict:
|
||||
"""
|
||||
Get comprehensive quota status for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Complete quota status dict
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# Get daily usage
|
||||
daily = conn.execute(
|
||||
"SELECT conversions FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, today),
|
||||
).fetchone()
|
||||
|
||||
conversions_used = daily["conversions"] if daily else 0
|
||||
conversions_limit = QuotaLimits.CONVERSIONS_PER_DAY[plan]
|
||||
|
||||
return {
|
||||
"plan": plan,
|
||||
"conversions": {
|
||||
"used": conversions_used,
|
||||
"limit": conversions_limit,
|
||||
"remaining": conversions_limit - conversions_used
|
||||
if conversions_limit != float("inf")
|
||||
else -1,
|
||||
"reset_at": (datetime.utcnow() + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0
|
||||
),
|
||||
},
|
||||
"file_size_limit_mb": QuotaLimits.MAX_FILE_SIZE_MB[plan],
|
||||
"storage_limit_mb": QuotaLimits.STORAGE_LIMIT_MB[plan],
|
||||
"api_rate_limit": QuotaLimits.API_RATE_LIMIT[plan],
|
||||
"concurrent_jobs": QuotaLimits.CONCURRENT_JOBS[plan],
|
||||
"batch_file_limit": QuotaLimits.BATCH_FILE_LIMIT[plan],
|
||||
"features": list(QuotaLimits.PREMIUM_FEATURES[plan]),
|
||||
"can_batch_process": QuotaService.has_feature(
|
||||
user_id, "batch_processing"
|
||||
),
|
||||
"can_use_api": QuotaService.has_feature(user_id, "api_access"),
|
||||
"can_schedule_delivery": QuotaService.has_feature(
|
||||
user_id, "email_delivery"
|
||||
),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def get_monthly_storage_usage(user_id: int, year: int, month: int) -> float:
|
||||
"""Get storage usage for a specific month"""
|
||||
month_key = f"{year}-{month:02d}"
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT total_size_mb FROM storage_usage WHERE user_id = ? AND month = ?",
|
||||
(user_id, month_key),
|
||||
).fetchone()
|
||||
|
||||
return row["total_size_mb"] if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def upgrade_plan(user_id: int, new_plan: str) -> bool:
|
||||
"""
|
||||
Upgrade user to a new plan
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
new_plan: New plan ('pro' or 'business')
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if new_plan not in QuotaLimits.CONVERSIONS_PER_DAY:
|
||||
logger.error(f"Invalid plan: {new_plan}")
|
||||
return False
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE users SET plan = ?, updated_at = ? WHERE id = ?",
|
||||
(new_plan, _utc_now(), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"User {user_id} upgraded to {new_plan}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upgrade user plan: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def downgrade_plan(user_id: int, new_plan: str = "free") -> bool:
|
||||
"""Downgrade user to a lower plan"""
|
||||
return QuotaService.upgrade_plan(user_id, new_plan)
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def init_quota_db():
|
||||
return QuotaService.init_quota_db()
|
||||
|
||||
|
||||
def get_quota_status(user_id: int) -> Dict:
|
||||
return QuotaService.get_quota_status(user_id)
|
||||
|
||||
|
||||
def record_conversion(user_id: int, file_size_mb: float) -> bool:
|
||||
return QuotaService.record_conversion(user_id, file_size_mb)
|
||||
|
||||
|
||||
def check_rate_limit(user_id: int) -> Tuple[bool, int]:
|
||||
return QuotaService.check_rate_limit(user_id)
|
||||
|
||||
|
||||
def has_feature(user_id: int, feature: str) -> bool:
|
||||
return QuotaService.has_feature(user_id, feature)
|
||||
@@ -2,7 +2,9 @@
|
||||
from flask import session
|
||||
|
||||
TASK_ACCESS_SESSION_KEY = "task_access_ids"
|
||||
STAGED_UPLOAD_SESSION_KEY = "staged_upload_ids"
|
||||
MAX_TRACKED_TASK_IDS = 200
|
||||
MAX_TRACKED_STAGED_UPLOAD_IDS = 50
|
||||
|
||||
|
||||
def get_current_user_id() -> int | None:
|
||||
@@ -28,14 +30,34 @@ def has_session_task_access(task_id: str) -> bool:
|
||||
return isinstance(tracked, list) and task_id in tracked
|
||||
|
||||
|
||||
def remember_staged_upload(upload_id: str):
|
||||
"""Persist one staged upload id in the active browser session."""
|
||||
tracked = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
if not isinstance(tracked, list):
|
||||
tracked = []
|
||||
|
||||
normalized = [value for value in tracked if isinstance(value, str) and value != upload_id]
|
||||
normalized.append(upload_id)
|
||||
session[STAGED_UPLOAD_SESSION_KEY] = normalized[-MAX_TRACKED_STAGED_UPLOAD_IDS:]
|
||||
|
||||
|
||||
def has_staged_upload_access(upload_id: str) -> bool:
|
||||
"""Return whether the active browser session owns one staged upload id."""
|
||||
tracked = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
return isinstance(tracked, list) and upload_id in tracked
|
||||
|
||||
|
||||
def login_user_session(user_id: int):
|
||||
"""Persist the authenticated user in the Flask session."""
|
||||
tracked_task_ids = session.get(TASK_ACCESS_SESSION_KEY, [])
|
||||
staged_upload_ids = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = user_id
|
||||
if isinstance(tracked_task_ids, list) and tracked_task_ids:
|
||||
session[TASK_ACCESS_SESSION_KEY] = tracked_task_ids[-MAX_TRACKED_TASK_IDS:]
|
||||
if isinstance(staged_upload_ids, list) and staged_upload_ids:
|
||||
session[STAGED_UPLOAD_SESSION_KEY] = staged_upload_ids[-MAX_TRACKED_STAGED_UPLOAD_IDS:]
|
||||
|
||||
|
||||
def logout_user_session():
|
||||
|
||||
Reference in New Issue
Block a user