From 77ca70cd48de4c8b55f0c2f49c556b776e6eaef6 Mon Sep 17 00:00:00 2001 From: Michele Date: Tue, 31 Mar 2026 20:01:07 +0200 Subject: [PATCH] feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel BLOCCO 1 - Multi-user data model: - User: email, display_name, avatar_url, auth_provider, google_id - User: subscription_plan, subscription_expires_at, is_admin, post counters - SubscriptionCode table per redeem codes - user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting - Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti BLOCCO 2 - Auth completo: - Registrazione email/password + login multi-user - Google OAuth 2.0 (httpx, no deps esterne) - Callback flow: Google -> /auth/callback?token=JWT -> frontend - Backward compat login admin con username BLOCCO 3 - Piani e abbonamenti: - Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans - Pro: illimitato, tutte le piattaforme, tutte le feature - Enforcement automatico in tutti i router - Redeem codes con durate 1/3/6/12 mesi - Admin panel: genera codici, lista utenti BLOCCO 4 - Frontend completo: - Login page design Leopost (split coral/cream, Google, social coming soon) - AuthCallback per OAuth redirect - PlanBanner, UpgradeModal con pricing - AdminSettings per generazione codici - CharacterForm con tab Account Social + guide setup Deploy: - Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE - docker-compose.prod.yml per leopost.it (no subpath) - docker-compose.yml aggiornato per lab Co-Authored-By: Claude Sonnet 4.6 --- .vps-lab-config.json | 3 +- Dockerfile | 4 + backend/app/auth.py | 38 +- backend/app/config.py | 12 +- backend/app/database.py | 68 ++- backend/app/main.py | 42 +- backend/app/models.py | 34 +- backend/app/plan_limits.py | 49 ++ backend/app/routers/admin.py | 113 ++++ backend/app/routers/affiliates.py | 56 +- backend/app/routers/auth.py | 277 +++++++++ backend/app/routers/characters.py | 64 +- backend/app/routers/comments.py | 76 ++- backend/app/routers/content.py | 119 +++- backend/app/routers/plans.py | 96 ++- backend/app/routers/settings.py | 97 +++- backend/app/routers/social.py | 77 ++- backend/app/schemas.py | 1 + docker-compose.prod.yml | 29 + docker-compose.yml | 8 +- frontend/src/App.jsx | 10 +- frontend/src/AuthContext.jsx | 87 ++- frontend/src/api.js | 16 +- frontend/src/components/AdminSettings.jsx | 344 +++++++++++ frontend/src/components/AuthCallback.jsx | 37 ++ frontend/src/components/CharacterForm.jsx | 677 ++++++++++++++++------ frontend/src/components/Dashboard.jsx | 26 +- frontend/src/components/LoginPage.jsx | 461 +++++++++++++-- frontend/src/components/PlanBanner.jsx | 93 +++ frontend/src/components/UpgradeModal.jsx | 247 ++++++++ frontend/vite.config.js | 6 +- 31 files changed, 2818 insertions(+), 449 deletions(-) create mode 100644 backend/app/plan_limits.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/auth.py create mode 100644 docker-compose.prod.yml create mode 100644 frontend/src/components/AdminSettings.jsx create mode 100644 frontend/src/components/AuthCallback.jsx create mode 100644 frontend/src/components/PlanBanner.jsx create mode 100644 frontend/src/components/UpgradeModal.jsx diff --git a/.vps-lab-config.json b/.vps-lab-config.json index 0d8057e..b01a602 100644 --- a/.vps-lab-config.json +++ b/.vps-lab-config.json @@ -8,8 +8,9 @@ "clone_url": "https://git.mlhub.it/Michele/leopost-full.git" }, "vps": { - "deployed": false, + "deployed": true, "url": "https://lab.mlhub.it/leopost-full/", + "last_deploy": "2026-03-31T15:26:00Z", "container": "lab-leopost-full-app", "path": "/opt/lab-leopost-full/" }, diff --git a/Dockerfile b/Dockerfile index 9740195..6d750ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ WORKDIR /app/frontend COPY frontend/package*.json ./ RUN npm install COPY frontend/ ./ +ARG VITE_BASE_PATH=/leopost-full +ARG VITE_API_BASE=/leopost-full/api +ENV VITE_BASE_PATH=$VITE_BASE_PATH +ENV VITE_API_BASE=$VITE_API_BASE RUN npm run build # Stage 2: Python backend + frontend built diff --git a/backend/app/auth.py b/backend/app/auth.py index c413718..91b27da 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,7 +1,9 @@ +"""Core authentication utilities used throughout the application.""" + from datetime import datetime, timedelta import bcrypt -from fastapi import APIRouter, Depends, HTTPException +from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy.orm import Session @@ -9,9 +11,7 @@ from sqlalchemy.orm import Session from .config import settings from .database import get_db from .models import User -from .schemas import LoginRequest, Token -router = APIRouter(prefix="/api/auth", tags=["auth"]) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") @@ -30,31 +30,29 @@ def create_access_token(data: dict) -> str: return jwt.encode(to_encode, settings.secret_key, algorithm="HS256") +def get_user_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ) -> User: try: payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) - username: str = payload.get("sub") - if username is None: + # Support both sub (user_id) and legacy username + user_id = payload.get("user_id") + username = payload.get("sub") + if user_id is None and username is None: raise HTTPException(status_code=401, detail="Invalid token") except JWTError: raise HTTPException(status_code=401, detail="Invalid token") - user = db.query(User).filter(User.username == username).first() + + if user_id is not None: + user = db.query(User).filter(User.id == user_id).first() + else: + # Legacy: username-based token + user = db.query(User).filter(User.username == username).first() + if user is None: raise HTTPException(status_code=401, detail="User not found") return user - - -@router.post("/login", response_model=Token) -def login(request: LoginRequest, db: Session = Depends(get_db)): - user = db.query(User).filter(User.username == request.username).first() - if not user or not verify_password(request.password, user.hashed_password): - raise HTTPException(status_code=401, detail="Invalid credentials") - token = create_access_token({"sub": user.username}) - return Token(access_token=token) - - -@router.get("/me") -def me(user: User = Depends(get_current_user)): - return {"username": user.username} diff --git a/backend/app/config.py b/backend/app/config.py index 88484d0..570c1c7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,13 +3,23 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): database_url: str = "sqlite:///./data/leopost.db" - secret_key: str = "change-me-to-a-random-secret-key" + secret_key: str = "leopost-secret-change-in-production-2026" admin_username: str = "admin" admin_password: str = "changeme" access_token_expire_minutes: int = 1440 # 24h + # Google OAuth + # Reads from env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET + google_client_id: str = "" + google_client_secret: str = "" + + # App base URL (used for OAuth redirects) + # Reads from env var: APP_URL + app_url: str = "https://leopost.it" + class Config: env_file = ".env" + extra = "ignore" settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py index 957dc14..e751b4d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker, declarative_base from .config import settings @@ -18,3 +18,69 @@ def get_db(): yield db finally: db.close() + + +def run_migrations(engine): + """SQLite-safe migration: add new columns if they don't exist.""" + migrations = { + "users": [ + ("email", "VARCHAR"), + ("display_name", "VARCHAR"), + ("avatar_url", "VARCHAR"), + ("auth_provider", "VARCHAR DEFAULT 'local'"), + ("google_id", "VARCHAR"), + ("subscription_plan", "VARCHAR DEFAULT 'freemium'"), + ("subscription_expires_at", "DATETIME"), + ("is_admin", "BOOLEAN DEFAULT 0"), + ("posts_generated_this_month", "INTEGER DEFAULT 0"), + ("posts_reset_date", "DATE"), + ], + "characters": [("user_id", "INTEGER")], + "posts": [("user_id", "INTEGER")], + "affiliate_links": [("user_id", "INTEGER")], + "editorial_plans": [("user_id", "INTEGER")], + "social_accounts": [("user_id", "INTEGER")], + "system_settings": [("user_id", "INTEGER")], + } + with engine.connect() as conn: + for table, cols in migrations.items(): + try: + existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))} + for col_name, col_def in cols: + if col_name not in existing: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def}")) + conn.commit() + except Exception as e: + print(f"Migration warning for {table}: {e}") + + # Fix system_settings: remove UNIQUE constraint on 'key' by recreating the table + # This allows per-user settings (same key, different user_id) + try: + indexes = list(conn.execute(text("PRAGMA index_list(system_settings)"))) + has_unique_key = any( + row[1].lower().startswith("ix_") or "key" in row[1].lower() + for row in indexes + if row[2] == 1 # unique=1 + ) + # Check via table creation SQL + create_sql_row = conn.execute(text( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='system_settings'" + )).fetchone() + if create_sql_row and "UNIQUE" in (create_sql_row[0] or "").upper(): + # Recreate without UNIQUE on key + conn.execute(text("ALTER TABLE system_settings RENAME TO system_settings_old")) + conn.execute(text(""" + CREATE TABLE system_settings ( + id INTEGER PRIMARY KEY, + key VARCHAR(100) NOT NULL, + value JSON, + updated_at DATETIME, + user_id INTEGER REFERENCES users(id) + ) + """)) + conn.execute(text("INSERT INTO system_settings SELECT id, key, value, updated_at, user_id FROM system_settings_old")) + conn.execute(text("DROP TABLE system_settings_old")) + conn.commit() + print("Migration: system_settings UNIQUE constraint on key removed.") + except Exception as e: + print(f"Migration warning for system_settings UNIQUE fix: {e}") diff --git a/backend/app/main.py b/backend/app/main.py index fff3859..4f32fcf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,10 +16,11 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from .auth import hash_password -from .auth import router as auth_router from .config import settings -from .database import Base, SessionLocal, engine +from .database import Base, SessionLocal, engine, run_migrations from .models import User +from .routers.admin import router as admin_router +from .routers.auth import router as auth_router from .routers.affiliates import router as affiliates_router from .routers.characters import router as characters_router from .routers.comments import router as comments_router @@ -78,10 +79,13 @@ async def lifespan(app: FastAPI): data_dir = Path("./data") data_dir.mkdir(parents=True, exist_ok=True) - # Create tables + # Run migrations FIRST (add new columns to existing tables) + run_migrations(engine) + + # Create tables (for new tables like subscription_codes) Base.metadata.create_all(bind=engine) - # Create admin user if not exists + # Create or update admin user db = SessionLocal() try: existing = db.query(User).filter(User.username == settings.admin_username).first() @@ -89,9 +93,28 @@ async def lifespan(app: FastAPI): admin = User( username=settings.admin_username, hashed_password=hash_password(settings.admin_password), + email="admin@leopost.it", + display_name="Admin", + auth_provider="local", + subscription_plan="pro", + is_admin=True, ) db.add(admin) db.commit() + else: + # Update existing admin to ensure proper flags + updated = False + if not existing.is_admin: + existing.is_admin = True + updated = True + if existing.subscription_plan != "pro": + existing.subscription_plan = "pro" + updated = True + if not existing.email: + existing.email = "admin@leopost.it" + updated = True + if updated: + db.commit() finally: db.close() @@ -114,13 +137,17 @@ async def lifespan(app: FastAPI): # CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead. app = FastAPI( title="Leopost Full", - version="0.1.0", + version="0.2.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=[ + "http://localhost:5173", + "https://leopost.it", + "https://www.leopost.it", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -131,6 +158,7 @@ app.add_middleware( # --------------------------------------------------------------------------- app.include_router(auth_router) +app.include_router(admin_router) app.include_router(characters_router) app.include_router(content_router) app.include_router(affiliates_router) @@ -143,7 +171,7 @@ app.include_router(editorial_router) @app.get("/api/health") def health(): - return {"status": "ok", "version": "0.1.0"} + return {"status": "ok", "version": "0.2.0"} # --------------------------------------------------------------------------- diff --git a/backend/app/models.py b/backend/app/models.py index 979fbf2..82c037e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text from .database import Base @@ -15,6 +15,30 @@ class User(Base): hashed_password = Column(String(255), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) + # Multi-user SaaS fields + email = Column(String(255), unique=True, nullable=True) + display_name = Column(String(100), nullable=True) + avatar_url = Column(String(500), nullable=True) + auth_provider = Column(String(50), default="local") + google_id = Column(String(200), unique=True, nullable=True) + subscription_plan = Column(String(50), default="freemium") + subscription_expires_at = Column(DateTime, nullable=True) + is_admin = Column(Boolean, default=False) + posts_generated_this_month = Column(Integer, default=0) + posts_reset_date = Column(Date, nullable=True) + + +class SubscriptionCode(Base): + __tablename__ = "subscription_codes" + + id = Column(Integer, primary_key=True) + code = Column(String(100), unique=True, nullable=False) + duration_months = Column(Integer, nullable=False) # 1, 3, 6, 12 + created_by_admin_id = Column(Integer, ForeignKey("users.id")) + used_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + class Character(Base): __tablename__ = "characters" @@ -31,6 +55,7 @@ class Character(Base): is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # === Phase 2: Content Generation === @@ -53,6 +78,7 @@ class Post(Base): status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # === Phase 4: Affiliate Links === @@ -70,6 +96,7 @@ class AffiliateLink(Base): is_active = Column(Boolean, default=True) click_count = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # === Phase 5: Scheduling === @@ -90,6 +117,7 @@ class EditorialPlan(Base): is_active = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) class ScheduledPost(Base): @@ -124,6 +152,7 @@ class SocialAccount(Base): extra_data = Column(JSON, default=dict) # platform-specific data is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # === Phase 11: Comment Management === @@ -151,6 +180,7 @@ class SystemSetting(Base): __tablename__ = "system_settings" id = Column(Integer, primary_key=True, index=True) - key = Column(String(100), unique=True, nullable=False) + key = Column(String(100), nullable=False) value = Column(JSON) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) diff --git a/backend/app/plan_limits.py b/backend/app/plan_limits.py new file mode 100644 index 0000000..fe850f0 --- /dev/null +++ b/backend/app/plan_limits.py @@ -0,0 +1,49 @@ +"""Piano di abbonamento e limiti per feature.""" + +PLAN_LIMITS = { + "freemium": { + "characters_max": 1, + "posts_per_month": 15, + "platforms": ["facebook", "instagram"], + "auto_plans": False, + "comments_management": False, + "affiliate_links": False, + "editorial_calendar_max": 5, + }, + "pro": { + "characters_max": None, + "posts_per_month": None, + "platforms": ["facebook", "instagram", "youtube", "tiktok"], + "auto_plans": True, + "comments_management": True, + "affiliate_links": True, + "editorial_calendar_max": None, + }, +} + + +def get_plan(user) -> dict: + """Returns the effective plan for a user (checks expiry for pro).""" + from datetime import datetime + plan = getattr(user, "subscription_plan", "freemium") or "freemium" + if plan == "pro": + expires = getattr(user, "subscription_expires_at", None) + if expires and expires < datetime.utcnow(): + return PLAN_LIMITS["freemium"] + return PLAN_LIMITS.get(plan, PLAN_LIMITS["freemium"]) + + +def check_limit(user, feature: str, current_count: int = 0) -> tuple[bool, str]: + """Returns (allowed, error_message).""" + limits = get_plan(user) + limit = limits.get(feature) + if limit is None: + return True, "" + if isinstance(limit, bool): + if not limit: + return False, f"Feature '{feature}' non disponibile nel piano Freemium. Passa a Pro." + return True, "" + if isinstance(limit, int): + if current_count >= limit: + return False, f"Hai raggiunto il limite del piano Freemium ({limit} {feature}). Passa a Pro." + return True, "" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..da885ca --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,113 @@ +"""Admin router — user management and subscription code generation.""" + +import secrets +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..auth import get_current_user +from ..database import get_db +from ..models import SubscriptionCode, User + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +def _require_admin(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Accesso riservato agli amministratori.") + return current_user + + +class GenerateCodeRequest(BaseModel): + duration_months: int # 1, 3, 6, 12 + + +@router.get("/users") +def list_users( + db: Session = Depends(get_db), + admin: User = Depends(_require_admin), +): + """List all users (admin only).""" + users = db.query(User).order_by(User.created_at.desc()).all() + return [ + { + "id": u.id, + "email": u.email, + "username": u.username, + "display_name": u.display_name, + "subscription_plan": u.subscription_plan or "freemium", + "subscription_expires_at": u.subscription_expires_at.isoformat() if u.subscription_expires_at else None, + "is_admin": bool(u.is_admin), + "auth_provider": u.auth_provider or "local", + "created_at": u.created_at.isoformat() if u.created_at else None, + } + for u in users + ] + + +@router.post("/codes/generate") +def generate_code( + request: GenerateCodeRequest, + db: Session = Depends(get_db), + admin: User = Depends(_require_admin), +): + """Generate a new Pro subscription code (admin only).""" + if request.duration_months not in (1, 3, 6, 12): + raise HTTPException(status_code=400, detail="duration_months deve essere 1, 3, 6 o 12.") + + raw = secrets.token_urlsafe(12).upper()[:12] + code_str = f"LP-{raw}" + + # Ensure uniqueness + attempts = 0 + while db.query(SubscriptionCode).filter(SubscriptionCode.code == code_str).first(): + raw = secrets.token_urlsafe(12).upper()[:12] + code_str = f"LP-{raw}" + attempts += 1 + if attempts > 10: + raise HTTPException(status_code=500, detail="Impossibile generare codice univoco.") + + code = SubscriptionCode( + code=code_str, + duration_months=request.duration_months, + created_by_admin_id=admin.id, + ) + db.add(code) + db.commit() + db.refresh(code) + + return { + "code": code.code, + "duration_months": code.duration_months, + "created_at": code.created_at.isoformat(), + } + + +@router.get("/codes") +def list_codes( + db: Session = Depends(get_db), + admin: User = Depends(_require_admin), +): + """List all subscription codes (admin only).""" + codes = db.query(SubscriptionCode).order_by(SubscriptionCode.created_at.desc()).all() + result = [] + for c in codes: + used_by_email = None + if c.used_by_user_id: + used_user = db.query(User).filter(User.id == c.used_by_user_id).first() + if used_user: + used_by_email = used_user.email or used_user.username + + result.append({ + "id": c.id, + "code": c.code, + "duration_months": c.duration_months, + "status": "used" if c.used_by_user_id else "active", + "used_by": used_by_email, + "used_at": c.used_at.isoformat() if c.used_at else None, + "created_at": c.created_at.isoformat() if c.created_at else None, + }) + return result diff --git a/backend/app/routers/affiliates.py b/backend/app/routers/affiliates.py index ebebfd1..520ebe3 100644 --- a/backend/app/routers/affiliates.py +++ b/backend/app/routers/affiliates.py @@ -8,13 +8,13 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import AffiliateLink +from ..models import AffiliateLink, User +from ..plan_limits import get_plan from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate router = APIRouter( prefix="/api/affiliates", tags=["affiliates"], - dependencies=[Depends(get_current_user)], ) @@ -22,27 +22,48 @@ router = APIRouter( def list_affiliate_links( character_id: int | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List all affiliate links, optionally filtered by character.""" - query = db.query(AffiliateLink) + query = db.query(AffiliateLink).filter(AffiliateLink.user_id == current_user.id) if character_id is not None: query = query.filter(AffiliateLink.character_id == character_id) return query.order_by(AffiliateLink.created_at.desc()).all() @router.get("/{link_id}", response_model=AffiliateLinkResponse) -def get_affiliate_link(link_id: int, db: Session = Depends(get_db)): +def get_affiliate_link( + link_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get a single affiliate link by ID.""" - link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() + link = ( + db.query(AffiliateLink) + .filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id) + .first() + ) if not link: raise HTTPException(status_code=404, detail="Affiliate link not found") return link @router.post("/", response_model=AffiliateLinkResponse, status_code=201) -def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_db)): +def create_affiliate_link( + data: AffiliateLinkCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Create a new affiliate link.""" + plan = get_plan(current_user) + if not plan.get("affiliate_links"): + raise HTTPException( + status_code=403, + detail={"message": "Affiliate links disponibili solo con Pro.", "upgrade_required": True}, + ) + link = AffiliateLink(**data.model_dump()) + link.user_id = current_user.id db.add(link) db.commit() db.refresh(link) @@ -51,10 +72,17 @@ def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_d @router.put("/{link_id}", response_model=AffiliateLinkResponse) def update_affiliate_link( - link_id: int, data: AffiliateLinkUpdate, db: Session = Depends(get_db) + link_id: int, + data: AffiliateLinkUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Update an affiliate link.""" - link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() + link = ( + db.query(AffiliateLink) + .filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id) + .first() + ) if not link: raise HTTPException(status_code=404, detail="Affiliate link not found") update_data = data.model_dump(exclude_unset=True) @@ -66,9 +94,17 @@ def update_affiliate_link( @router.delete("/{link_id}", status_code=204) -def delete_affiliate_link(link_id: int, db: Session = Depends(get_db)): +def delete_affiliate_link( + link_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Delete an affiliate link.""" - link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first() + link = ( + db.query(AffiliateLink) + .filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id) + .first() + ) if not link: raise HTTPException(status_code=404, detail="Affiliate link not found") db.delete(link) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..178804b --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,277 @@ +"""Authentication router — multi-user SaaS with local + Google OAuth.""" + +import secrets +import uuid +from datetime import datetime, timedelta +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..auth import ( + create_access_token, + get_current_user, + get_user_by_email, + hash_password, + verify_password, +) +from ..config import settings +from ..database import get_db +from ..models import SubscriptionCode, User + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +# === Schemas === + +class RegisterRequest(BaseModel): + email: str + password: str + display_name: Optional[str] = None + + +class LoginRequest(BaseModel): + email: Optional[str] = None + username: Optional[str] = None # backward compat + password: str + + +class RedeemCodeRequest(BaseModel): + code: str + + +def _user_response(user: User) -> dict: + return { + "id": user.id, + "email": user.email, + "username": user.username, + "display_name": user.display_name, + "avatar_url": user.avatar_url, + "subscription_plan": user.subscription_plan or "freemium", + "subscription_expires_at": user.subscription_expires_at.isoformat() if user.subscription_expires_at else None, + "is_admin": bool(user.is_admin), + "posts_generated_this_month": user.posts_generated_this_month or 0, + } + + +# === Endpoints === + +@router.post("/register") +def register(request: RegisterRequest, db: Session = Depends(get_db)): + """Register a new user with email + password.""" + # Check email uniqueness + existing = get_user_by_email(db, request.email) + if existing: + raise HTTPException(status_code=400, detail="Email già registrata.") + + # Build username from email for backward compat + username_base = request.email.split("@")[0] + username = username_base + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{username_base}{counter}" + counter += 1 + + user = User( + username=username, + hashed_password=hash_password(request.password), + email=request.email, + display_name=request.display_name or username, + auth_provider="local", + subscription_plan="freemium", + is_admin=False, + ) + db.add(user) + db.commit() + db.refresh(user) + + token = create_access_token({"sub": user.username, "user_id": user.id}) + return { + "access_token": token, + "token_type": "bearer", + "user": _user_response(user), + } + + +@router.post("/login") +def login(request: LoginRequest, db: Session = Depends(get_db)): + """Login with email (or legacy username) + password.""" + user = None + + if request.email: + user = get_user_by_email(db, request.email) + elif request.username: + # Backward compat: check username OR email + user = db.query(User).filter(User.username == request.username).first() + if not user: + user = get_user_by_email(db, request.username) + + if not user or not verify_password(request.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Credenziali non valide.") + + token = create_access_token({"sub": user.username, "user_id": user.id}) + return { + "access_token": token, + "token_type": "bearer", + "user": _user_response(user), + } + + +@router.get("/me") +def me(user: User = Depends(get_current_user)): + """Get current authenticated user info.""" + return _user_response(user) + + +@router.post("/logout") +def logout(): + """Logout — client should remove the token.""" + return {"message": "ok"} + + +# === Google OAuth === + +@router.get("/oauth/google") +def oauth_google_start(): + """Redirect to Google OAuth consent screen.""" + if not settings.google_client_id: + raise HTTPException(status_code=501, detail="Google OAuth non configurato.") + + state_token = str(uuid.uuid4()) + params = { + "client_id": settings.google_client_id, + "redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback", + "response_type": "code", + "scope": "openid email profile", + "state": state_token, + "access_type": "offline", + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{query}" + return RedirectResponse(url=auth_url) + + +@router.get("/oauth/google/callback") +async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)): + """Exchange Google OAuth code for token, create/find user, redirect with JWT.""" + if not settings.google_client_id or not settings.google_client_secret: + raise HTTPException(status_code=501, detail="Google OAuth non configurato.") + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback", + "grant_type": "authorization_code", + }, + ) + if token_resp.status_code != 200: + raise HTTPException(status_code=400, detail="Errore scambio token Google.") + token_data = token_resp.json() + + # Get user info + userinfo_resp = await client.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {token_data['access_token']}"}, + ) + if userinfo_resp.status_code != 200: + raise HTTPException(status_code=400, detail="Errore recupero profilo Google.") + google_user = userinfo_resp.json() + + google_id = google_user.get("sub") + email = google_user.get("email") + name = google_user.get("name") + picture = google_user.get("picture") + + # Find existing user by google_id or email + user = db.query(User).filter(User.google_id == google_id).first() + if not user and email: + user = get_user_by_email(db, email) + + if user: + # Update google_id and avatar if missing + if not user.google_id: + user.google_id = google_id + if not user.avatar_url and picture: + user.avatar_url = picture + db.commit() + else: + # Create new user + username_base = (email or google_id).split("@")[0] + username = username_base + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{username_base}{counter}" + counter += 1 + + user = User( + username=username, + hashed_password=hash_password(secrets.token_urlsafe(32)), + email=email, + display_name=name or username, + avatar_url=picture, + auth_provider="google", + google_id=google_id, + subscription_plan="freemium", + is_admin=False, + ) + db.add(user) + db.commit() + db.refresh(user) + + jwt_token = create_access_token({"sub": user.username, "user_id": user.id}) + redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}" + return RedirectResponse(url=redirect_url) + + +# === Subscription code redemption === + +@router.post("/redeem") +def redeem_code( + request: RedeemCodeRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Redeem a Pro subscription code.""" + code = db.query(SubscriptionCode).filter( + SubscriptionCode.code == request.code.upper().strip() + ).first() + + if not code: + raise HTTPException(status_code=404, detail="Codice non trovato.") + if code.used_by_user_id is not None: + raise HTTPException(status_code=400, detail="Codice già utilizzato.") + + # Calculate new expiry + now = datetime.utcnow() + current_expiry = current_user.subscription_expires_at + if current_user.subscription_plan == "pro" and current_expiry and current_expiry > now: + # Extend existing pro subscription + base_date = current_expiry + else: + base_date = now + + new_expiry = base_date + timedelta(days=30 * code.duration_months) + + # Update user + current_user.subscription_plan = "pro" + current_user.subscription_expires_at = new_expiry + + # Mark code as used + code.used_by_user_id = current_user.id + code.used_at = now + + db.commit() + db.refresh(current_user) + + return { + "subscription_plan": current_user.subscription_plan, + "subscription_expires_at": current_user.subscription_expires_at.isoformat(), + } diff --git a/backend/app/routers/characters.py b/backend/app/routers/characters.py index 8a126f9..664c58a 100644 --- a/backend/app/routers/characters.py +++ b/backend/app/routers/characters.py @@ -5,32 +5,59 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import Character +from ..models import Character, User +from ..plan_limits import check_limit from ..schemas import CharacterCreate, CharacterResponse, CharacterUpdate router = APIRouter( prefix="/api/characters", tags=["characters"], - dependencies=[Depends(get_current_user)], ) @router.get("/", response_model=list[CharacterResponse]) -def list_characters(db: Session = Depends(get_db)): - return db.query(Character).order_by(Character.created_at.desc()).all() +def list_characters( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return ( + db.query(Character) + .filter(Character.user_id == current_user.id) + .order_by(Character.created_at.desc()) + .all() + ) @router.get("/{character_id}", response_model=CharacterResponse) -def get_character(character_id: int, db: Session = Depends(get_db)): - character = db.query(Character).filter(Character.id == character_id).first() +def get_character( + character_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + character = ( + db.query(Character) + .filter(Character.id == character_id, Character.user_id == current_user.id) + .first() + ) if not character: raise HTTPException(status_code=404, detail="Character not found") return character @router.post("/", response_model=CharacterResponse, status_code=201) -def create_character(data: CharacterCreate, db: Session = Depends(get_db)): +def create_character( + data: CharacterCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Check plan limit for characters + count = db.query(Character).filter(Character.user_id == current_user.id).count() + allowed, msg = check_limit(current_user, "characters_max", count) + if not allowed: + raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True}) + character = Character(**data.model_dump()) + character.user_id = current_user.id db.add(character) db.commit() db.refresh(character) @@ -39,9 +66,16 @@ def create_character(data: CharacterCreate, db: Session = Depends(get_db)): @router.put("/{character_id}", response_model=CharacterResponse) def update_character( - character_id: int, data: CharacterUpdate, db: Session = Depends(get_db) + character_id: int, + data: CharacterUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - character = db.query(Character).filter(Character.id == character_id).first() + character = ( + db.query(Character) + .filter(Character.id == character_id, Character.user_id == current_user.id) + .first() + ) if not character: raise HTTPException(status_code=404, detail="Character not found") update_data = data.model_dump(exclude_unset=True) @@ -54,8 +88,16 @@ def update_character( @router.delete("/{character_id}", status_code=204) -def delete_character(character_id: int, db: Session = Depends(get_db)): - character = db.query(Character).filter(Character.id == character_id).first() +def delete_character( + character_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + character = ( + db.query(Character) + .filter(Character.id == character_id, Character.user_id == current_user.id) + .first() + ) if not character: raise HTTPException(status_code=404, detail="Character not found") db.delete(character) diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 3829c4d..0413567 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting +from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting, User +from ..plan_limits import get_plan from ..schemas import CommentAction, CommentResponse from ..services.llm import get_llm_provider from ..services.social import get_publisher @@ -18,7 +19,6 @@ from ..services.social import get_publisher router = APIRouter( prefix="/api/comments", tags=["comments"], - dependencies=[Depends(get_current_user)], ) @@ -28,6 +28,7 @@ def list_comments( reply_status: str | None = Query(None), scheduled_post_id: int | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List comments with optional filters.""" query = db.query(Comment) @@ -41,8 +42,17 @@ def list_comments( @router.get("/pending", response_model=list[CommentResponse]) -def list_pending_comments(db: Session = Depends(get_db)): +def list_pending_comments( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get only pending comments (reply_status='pending').""" + plan = get_plan(current_user) + if not plan.get("comments_management"): + raise HTTPException( + status_code=403, + detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True}, + ) return ( db.query(Comment) .filter(Comment.reply_status == "pending") @@ -52,7 +62,11 @@ def list_pending_comments(db: Session = Depends(get_db)): @router.get("/{comment_id}", response_model=CommentResponse) -def get_comment(comment_id: int, db: Session = Depends(get_db)): +def get_comment( + comment_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get a single comment by ID.""" comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: @@ -62,9 +76,19 @@ def get_comment(comment_id: int, db: Session = Depends(get_db)): @router.post("/{comment_id}/action", response_model=CommentResponse) def action_on_comment( - comment_id: int, data: CommentAction, db: Session = Depends(get_db) + comment_id: int, + data: CommentAction, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Take action on a comment: approve, edit, or ignore.""" + plan = get_plan(current_user) + if not plan.get("comments_management"): + raise HTTPException( + status_code=403, + detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True}, + ) + comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: raise HTTPException(status_code=404, detail="Comment not found") @@ -88,7 +112,11 @@ def action_on_comment( @router.post("/{comment_id}/reply", response_model=CommentResponse) -def reply_to_comment(comment_id: int, db: Session = Depends(get_db)): +def reply_to_comment( + comment_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Send the approved reply via the social platform API.""" comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: @@ -100,7 +128,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)): if not comment.external_comment_id: raise HTTPException(status_code=400, detail="No external comment ID available for reply") - # Find the social account for this platform via the scheduled post if not comment.scheduled_post_id: raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post") @@ -131,7 +158,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)): detail=f"No active {comment.platform} account found for this character", ) - # Build publisher kwargs kwargs: dict = {} if account.platform == "facebook": kwargs["page_id"] = account.page_id @@ -156,13 +182,12 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)): @router.post("/fetch/{platform}") -def fetch_comments(platform: str, db: Session = Depends(get_db)): - """Fetch new comments from a platform for all published posts. - - Creates Comment records for any new comments not already in the database. - Uses LLM to generate AI-suggested replies for each new comment. - """ - # Get all published scheduled posts for this platform +def fetch_comments( + platform: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Fetch new comments from a platform for all published posts.""" published_posts = ( db.query(ScheduledPost) .filter( @@ -176,12 +201,17 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): if not published_posts: return {"new_comments": 0, "message": f"No published posts found for {platform}"} - # Get LLM settings for AI reply generation llm_provider_name = None llm_api_key = None llm_model = None for key in ("llm_provider", "llm_api_key", "llm_model"): - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id) + .first() + ) + if not setting: + setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first() if setting: if key == "llm_provider": llm_provider_name = setting.value @@ -195,17 +225,15 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): try: llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model) except ValueError: - pass # LLM not available, skip AI replies + pass new_comment_count = 0 for scheduled in published_posts: - # Get the post to find the character post = db.query(Post).filter(Post.id == scheduled.post_id).first() if not post: continue - # Find the social account account = ( db.query(SocialAccount) .filter( @@ -218,7 +246,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): if not account or not account.access_token: continue - # Build publisher kwargs kwargs: dict = {} if account.platform == "facebook": kwargs["page_id"] = account.page_id @@ -229,14 +256,13 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): publisher = get_publisher(account.platform, account.access_token, **kwargs) comments = publisher.get_comments(scheduled.external_post_id) except (RuntimeError, ValueError): - continue # Skip this post if API call fails + continue for ext_comment in comments: ext_id = ext_comment.get("id", "") if not ext_id: continue - # Check if comment already exists existing = ( db.query(Comment) .filter(Comment.external_comment_id == ext_id) @@ -245,7 +271,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): if existing: continue - # Generate AI suggested reply if LLM is available ai_reply = None if llm: try: @@ -261,9 +286,8 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)): ) ai_reply = llm.generate(prompt, system=system_prompt) except RuntimeError: - pass # Skip AI reply if generation fails + pass - # Create comment record comment = Comment( scheduled_post_id=scheduled.id, platform=platform, diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py index 3a26ae4..09ec5f3 100644 --- a/backend/app/routers/content.py +++ b/backend/app/routers/content.py @@ -3,14 +3,15 @@ Handles post generation via LLM, image generation, and CRUD operations on posts. """ -from datetime import datetime +from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import AffiliateLink, Character, Post, SystemSetting +from ..models import AffiliateLink, Character, Post, SystemSetting, User +from ..plan_limits import check_limit from ..schemas import ( GenerateContentRequest, GenerateImageRequest, @@ -24,30 +25,57 @@ from ..services.llm import get_llm_provider router = APIRouter( prefix="/api/content", tags=["content"], - dependencies=[Depends(get_current_user)], ) -def _get_setting(db: Session, key: str) -> str | None: - """Retrieve a system setting value by key.""" - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() +def _get_setting(db: Session, key: str, user_id: int = None) -> str | None: + """Retrieve a system setting value by key, preferring user-specific over global.""" + if user_id is not None: + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == user_id) + .first() + ) + if setting is not None: + return setting.value + # Fallback to global (no user_id) + setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first() if setting is None: return None return setting.value @router.post("/generate", response_model=PostResponse) -def generate_content(request: GenerateContentRequest, db: Session = Depends(get_db)): +def generate_content( + request: GenerateContentRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Generate content for a character using LLM.""" - # Validate character exists - character = db.query(Character).filter(Character.id == request.character_id).first() + # Validate character belongs to user + character = ( + db.query(Character) + .filter(Character.id == request.character_id, Character.user_id == current_user.id) + .first() + ) if not character: raise HTTPException(status_code=404, detail="Character not found") - # Get LLM settings - provider_name = request.provider or _get_setting(db, "llm_provider") - api_key = _get_setting(db, "llm_api_key") - model = request.model or _get_setting(db, "llm_model") + # Check monthly post limit + first_of_month = date.today().replace(day=1) + if current_user.posts_reset_date != first_of_month: + current_user.posts_generated_this_month = 0 + current_user.posts_reset_date = first_of_month + db.commit() + + allowed, msg = check_limit(current_user, "posts_per_month", current_user.posts_generated_this_month or 0) + if not allowed: + raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True}) + + # Get LLM settings (user-specific first, then global) + provider_name = request.provider or _get_setting(db, "llm_provider", current_user.id) + api_key = _get_setting(db, "llm_api_key", current_user.id) + model = request.model or _get_setting(db, "llm_model", current_user.id) if not provider_name: raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.") @@ -63,7 +91,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_ } # Create LLM provider and generate text - base_url = _get_setting(db, "llm_base_url") + base_url = _get_setting(db, "llm_base_url", current_user.id) llm = get_llm_provider(provider_name, api_key, model, base_url=base_url) text = generate_post_text( character=char_dict, @@ -82,6 +110,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_ db.query(AffiliateLink) .filter( AffiliateLink.is_active == True, + AffiliateLink.user_id == current_user.id, (AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None), ) .all() @@ -102,6 +131,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_ # Create post record post = Post( character_id=character.id, + user_id=current_user.id, content_type=request.content_type, text_content=text, hashtags=hashtags, @@ -112,29 +142,37 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_ status="draft", ) db.add(post) + + # Increment monthly counter + current_user.posts_generated_this_month = (current_user.posts_generated_this_month or 0) + 1 db.commit() db.refresh(post) return post @router.post("/generate-image", response_model=PostResponse) -def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)): +def generate_image( + request: GenerateImageRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Generate an image for a character and attach to a post.""" - # Validate character exists - character = db.query(Character).filter(Character.id == request.character_id).first() + character = ( + db.query(Character) + .filter(Character.id == request.character_id, Character.user_id == current_user.id) + .first() + ) if not character: raise HTTPException(status_code=404, detail="Character not found") - # Get image settings - provider_name = request.provider or _get_setting(db, "image_provider") - api_key = _get_setting(db, "image_api_key") + provider_name = request.provider or _get_setting(db, "image_provider", current_user.id) + api_key = _get_setting(db, "image_api_key", current_user.id) if not provider_name: raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.") if not api_key: raise HTTPException(status_code=400, detail="Image API key not configured. Set 'image_api_key' in settings.") - # Build prompt from character if not provided prompt = request.prompt if not prompt: style_hint = request.style_hint or "" @@ -146,13 +184,12 @@ def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)) f"Style: {style_desc} {style_hint}".strip() ) - # Generate image image_provider = get_image_provider(provider_name, api_key) image_url = image_provider.generate(prompt, size=request.size) - # Create a new post with the image post = Post( character_id=character.id, + user_id=current_user.id, content_type="image", image_url=image_url, platform_hint="instagram", @@ -169,9 +206,10 @@ def list_posts( character_id: int | None = Query(None), status: str | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List all posts with optional filters.""" - query = db.query(Post) + query = db.query(Post).filter(Post.user_id == current_user.id) if character_id is not None: query = query.filter(Post.character_id == character_id) if status is not None: @@ -180,18 +218,27 @@ def list_posts( @router.get("/posts/{post_id}", response_model=PostResponse) -def get_post(post_id: int, db: Session = Depends(get_db)): +def get_post( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get a single post by ID.""" - post = db.query(Post).filter(Post.id == post_id).first() + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() if not post: raise HTTPException(status_code=404, detail="Post not found") return post @router.put("/posts/{post_id}", response_model=PostResponse) -def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)): +def update_post( + post_id: int, + data: PostUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Update a post.""" - post = db.query(Post).filter(Post.id == post_id).first() + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() if not post: raise HTTPException(status_code=404, detail="Post not found") update_data = data.model_dump(exclude_unset=True) @@ -204,9 +251,13 @@ def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)): @router.delete("/posts/{post_id}", status_code=204) -def delete_post(post_id: int, db: Session = Depends(get_db)): +def delete_post( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Delete a post.""" - post = db.query(Post).filter(Post.id == post_id).first() + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() if not post: raise HTTPException(status_code=404, detail="Post not found") db.delete(post) @@ -214,9 +265,13 @@ def delete_post(post_id: int, db: Session = Depends(get_db)): @router.post("/posts/{post_id}/approve", response_model=PostResponse) -def approve_post(post_id: int, db: Session = Depends(get_db)): +def approve_post( + post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Approve a post (set status to 'approved').""" - post = db.query(Post).filter(Post.id == post_id).first() + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() if not post: raise HTTPException(status_code=404, detail="Post not found") post.status = "approved" diff --git a/backend/app/routers/plans.py b/backend/app/routers/plans.py index dc36c03..6706849 100644 --- a/backend/app/routers/plans.py +++ b/backend/app/routers/plans.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import EditorialPlan, ScheduledPost +from ..models import EditorialPlan, ScheduledPost, User +from ..plan_limits import get_plan from ..schemas import ( EditorialPlanCreate, EditorialPlanResponse, @@ -22,7 +23,6 @@ from ..schemas import ( router = APIRouter( prefix="/api/plans", tags=["plans"], - dependencies=[Depends(get_current_user)], ) @@ -33,9 +33,10 @@ router = APIRouter( def list_plans( character_id: int | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List all editorial plans, optionally filtered by character.""" - query = db.query(EditorialPlan) + query = db.query(EditorialPlan).filter(EditorialPlan.user_id == current_user.id) if character_id is not None: query = query.filter(EditorialPlan.character_id == character_id) return query.order_by(EditorialPlan.created_at.desc()).all() @@ -48,9 +49,16 @@ def list_all_scheduled_posts( date_from: datetime | None = Query(None), date_after: datetime | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Get all scheduled posts across all plans with optional filters.""" - query = db.query(ScheduledPost) + # Join with plans to filter by user + user_plan_ids = [ + p.id for p in db.query(EditorialPlan.id).filter(EditorialPlan.user_id == current_user.id).all() + ] + query = db.query(ScheduledPost).filter( + (ScheduledPost.plan_id.in_(user_plan_ids)) | (ScheduledPost.plan_id == None) + ) if platform is not None: query = query.filter(ScheduledPost.platform == platform) if status is not None: @@ -63,18 +71,38 @@ def list_all_scheduled_posts( @router.get("/{plan_id}", response_model=EditorialPlanResponse) -def get_plan(plan_id: int, db: Session = Depends(get_db)): +def get_plan( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get a single editorial plan by ID.""" - plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() + plan = ( + db.query(EditorialPlan) + .filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id) + .first() + ) if not plan: raise HTTPException(status_code=404, detail="Editorial plan not found") return plan @router.post("/", response_model=EditorialPlanResponse, status_code=201) -def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)): +def create_plan( + data: EditorialPlanCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Create a new editorial plan.""" + plan_limits = get_plan(current_user) + if not plan_limits.get("auto_plans"): + raise HTTPException( + status_code=403, + detail={"message": "Piani automatici disponibili solo con Pro.", "upgrade_required": True}, + ) + plan = EditorialPlan(**data.model_dump()) + plan.user_id = current_user.id db.add(plan) db.commit() db.refresh(plan) @@ -83,10 +111,17 @@ def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)): @router.put("/{plan_id}", response_model=EditorialPlanResponse) def update_plan( - plan_id: int, data: EditorialPlanUpdate, db: Session = Depends(get_db) + plan_id: int, + data: EditorialPlanUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Update an editorial plan.""" - plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() + plan = ( + db.query(EditorialPlan) + .filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id) + .first() + ) if not plan: raise HTTPException(status_code=404, detail="Editorial plan not found") update_data = data.model_dump(exclude_unset=True) @@ -99,21 +134,36 @@ def update_plan( @router.delete("/{plan_id}", status_code=204) -def delete_plan(plan_id: int, db: Session = Depends(get_db)): +def delete_plan( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Delete an editorial plan and its associated scheduled posts.""" - plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() + plan = ( + db.query(EditorialPlan) + .filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id) + .first() + ) if not plan: raise HTTPException(status_code=404, detail="Editorial plan not found") - # Delete associated scheduled posts first db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete() db.delete(plan) db.commit() @router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse) -def toggle_plan(plan_id: int, db: Session = Depends(get_db)): +def toggle_plan( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Toggle the is_active status of an editorial plan.""" - plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() + plan = ( + db.query(EditorialPlan) + .filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id) + .first() + ) if not plan: raise HTTPException(status_code=404, detail="Editorial plan not found") plan.is_active = not plan.is_active @@ -127,9 +177,17 @@ def toggle_plan(plan_id: int, db: Session = Depends(get_db)): @router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse]) -def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)): +def get_plan_scheduled_posts( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get all scheduled posts for a specific plan.""" - plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first() + plan = ( + db.query(EditorialPlan) + .filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id) + .first() + ) if not plan: raise HTTPException(status_code=404, detail="Editorial plan not found") return ( @@ -141,7 +199,11 @@ def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)): @router.post("/schedule", response_model=ScheduledPostResponse, status_code=201) -def schedule_post(data: ScheduledPostCreate, db: Session = Depends(get_db)): +def schedule_post( + data: ScheduledPostCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Manually schedule a post.""" scheduled = ScheduledPost(**data.model_dump()) db.add(scheduled) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index d5a4747..51cac37 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,6 +1,7 @@ """System settings router. Manages key-value system settings including API provider configuration. +Each user has their own private settings. """ from datetime import datetime @@ -10,51 +11,60 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import SystemSetting +from ..models import SystemSetting, User from ..schemas import SettingResponse, SettingUpdate router = APIRouter( prefix="/api/settings", tags=["settings"], - dependencies=[Depends(get_current_user)], ) @router.get("/", response_model=list[SettingResponse]) -def list_settings(db: Session = Depends(get_db)): - """Get all system settings.""" - settings = db.query(SystemSetting).order_by(SystemSetting.key).all() +def list_settings( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get all system settings for the current user.""" + settings = ( + db.query(SystemSetting) + .filter(SystemSetting.user_id == current_user.id) + .order_by(SystemSetting.key) + .all() + ) return settings @router.get("/providers/status") -def get_providers_status(db: Session = Depends(get_db)): - """Check which API providers are configured (have API keys set). - - Returns a dict indicating configuration status for each provider category. - """ - # Helper to check if a setting exists and has a truthy value +def get_providers_status( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Check which API providers are configured (have API keys set).""" def _has_setting(key: str) -> str | None: - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() + # User-specific first + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id) + .first() + ) + if not setting: + # Global fallback + setting = db.query(SystemSetting).filter( + SystemSetting.key == key, SystemSetting.user_id == None + ).first() if setting and setting.value: return setting.value if isinstance(setting.value, str) else str(setting.value) return None - # LLM provider llm_provider = _has_setting("llm_provider") llm_key = _has_setting("llm_api_key") - - # Image provider image_provider = _has_setting("image_provider") image_key = _has_setting("image_api_key") - - # Voice provider (future) voice_provider = _has_setting("voice_provider") voice_key = _has_setting("voice_api_key") - # Social platforms - check for any active social accounts from ..models import SocialAccount - social_platforms = {} for platform in ("facebook", "instagram", "youtube", "tiktok"): has_account = ( @@ -63,6 +73,7 @@ def get_providers_status(db: Session = Depends(get_db)): SocialAccount.platform == platform, SocialAccount.is_active == True, SocialAccount.access_token != None, + SocialAccount.user_id == current_user.id, ) .first() ) @@ -86,26 +97,40 @@ def get_providers_status(db: Session = Depends(get_db)): @router.get("/{key}", response_model=SettingResponse) -def get_setting(key: str, db: Session = Depends(get_db)): - """Get a single setting by key.""" - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() +def get_setting( + key: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a single setting by key (user-specific).""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id) + .first() + ) if not setting: raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") return setting @router.put("/{key}", response_model=SettingResponse) -def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)): - """Create or update a setting by key. - - If the setting exists, update its value. If not, create it. - """ - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() +def upsert_setting( + key: str, + data: SettingUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create or update a setting by key (user-specific).""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id) + .first() + ) if setting: setting.value = data.value setting.updated_at = datetime.utcnow() else: - setting = SystemSetting(key=key, value=data.value) + setting = SystemSetting(key=key, value=data.value, user_id=current_user.id) db.add(setting) db.commit() db.refresh(setting) @@ -113,9 +138,17 @@ def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)) @router.delete("/{key}", status_code=204) -def delete_setting(key: str, db: Session = Depends(get_db)): - """Delete a setting by key.""" - setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() +def delete_setting( + key: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a setting by key (user-specific).""" + setting = ( + db.query(SystemSetting) + .filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id) + .first() + ) if not setting: raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") db.delete(setting) diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py index 28a5e2b..8f26d89 100644 --- a/backend/app/routers/social.py +++ b/backend/app/routers/social.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import get_db -from ..models import Post, ScheduledPost, SocialAccount +from ..models import Post, ScheduledPost, SocialAccount, User from ..schemas import ( ScheduledPostResponse, SocialAccountCreate, @@ -22,7 +22,6 @@ from ..services.social import get_publisher router = APIRouter( prefix="/api/social", tags=["social"], - dependencies=[Depends(get_current_user)], ) @@ -33,27 +32,41 @@ router = APIRouter( def list_social_accounts( character_id: int | None = Query(None), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List all social accounts, optionally filtered by character.""" - query = db.query(SocialAccount) + query = db.query(SocialAccount).filter(SocialAccount.user_id == current_user.id) if character_id is not None: query = query.filter(SocialAccount.character_id == character_id) return query.order_by(SocialAccount.created_at.desc()).all() @router.get("/accounts/{account_id}", response_model=SocialAccountResponse) -def get_social_account(account_id: int, db: Session = Depends(get_db)): +def get_social_account( + account_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Get a single social account by ID.""" - account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() + account = ( + db.query(SocialAccount) + .filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="Social account not found") return account @router.post("/accounts", response_model=SocialAccountResponse, status_code=201) -def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_db)): +def create_social_account( + data: SocialAccountCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Create/connect a new social account.""" account = SocialAccount(**data.model_dump()) + account.user_id = current_user.id db.add(account) db.commit() db.refresh(account) @@ -62,10 +75,17 @@ def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_d @router.put("/accounts/{account_id}", response_model=SocialAccountResponse) def update_social_account( - account_id: int, data: SocialAccountUpdate, db: Session = Depends(get_db) + account_id: int, + data: SocialAccountUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Update a social account.""" - account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() + account = ( + db.query(SocialAccount) + .filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="Social account not found") update_data = data.model_dump(exclude_unset=True) @@ -77,9 +97,17 @@ def update_social_account( @router.delete("/accounts/{account_id}", status_code=204) -def delete_social_account(account_id: int, db: Session = Depends(get_db)): +def delete_social_account( + account_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Delete a social account.""" - account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() + account = ( + db.query(SocialAccount) + .filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="Social account not found") db.delete(account) @@ -87,9 +115,17 @@ def delete_social_account(account_id: int, db: Session = Depends(get_db)): @router.post("/accounts/{account_id}/test") -def test_social_account(account_id: int, db: Session = Depends(get_db)): +def test_social_account( + account_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Test connection to a social account by making a simple API call.""" - account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first() + account = ( + db.query(SocialAccount) + .filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="Social account not found") @@ -97,7 +133,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail="No access token configured for this account") try: - # Build kwargs based on platform kwargs: dict = {} if account.platform == "facebook": if not account.page_id: @@ -109,7 +144,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail="Instagram account requires ig_user_id") kwargs["ig_user_id"] = ig_user_id - # Try to instantiate the publisher (validates credentials format) get_publisher(account.platform, account.access_token, **kwargs) return {"status": "ok", "message": f"Connection to {account.platform} account is configured correctly"} @@ -123,7 +157,11 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)): @router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse) -def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)): +def publish_scheduled_post( + scheduled_post_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Manually trigger publishing of a scheduled post.""" scheduled = ( db.query(ScheduledPost) @@ -133,18 +171,17 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db) if not scheduled: raise HTTPException(status_code=404, detail="Scheduled post not found") - # Get the post content - post = db.query(Post).filter(Post.id == scheduled.post_id).first() + post = db.query(Post).filter(Post.id == scheduled.post_id, Post.user_id == current_user.id).first() if not post: raise HTTPException(status_code=404, detail="Associated post not found") - # Find the social account for this platform and character account = ( db.query(SocialAccount) .filter( SocialAccount.character_id == post.character_id, SocialAccount.platform == scheduled.platform, SocialAccount.is_active == True, + SocialAccount.user_id == current_user.id, ) .first() ) @@ -157,7 +194,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db) if not account.access_token: raise HTTPException(status_code=400, detail="Social account has no access token configured") - # Build publisher kwargs kwargs: dict = {} if account.platform == "facebook": kwargs["page_id"] = account.page_id @@ -170,7 +206,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db) publisher = get_publisher(account.platform, account.access_token, **kwargs) - # Determine publish method based on content type text = post.text_content or "" if post.hashtags: text = f"{text}\n\n{' '.join(post.hashtags)}" @@ -182,12 +217,10 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db) else: external_id = publisher.publish_text(text) - # Update scheduled post scheduled.status = "published" scheduled.published_at = datetime.utcnow() scheduled.external_post_id = external_id - # Update post status post.status = "published" post.updated_at = datetime.utcnow() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ad3891f..ff5b032 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -14,6 +14,7 @@ class LoginRequest(BaseModel): class Token(BaseModel): access_token: str token_type: str = "bearer" + user: Optional[dict] = None # === Characters === diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..59e78c7 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,29 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + VITE_BASE_PATH: "" + VITE_API_BASE: "/api" + container_name: prod-leopost-full-app + restart: unless-stopped + volumes: + - ./data:/app/data + environment: + - DATABASE_URL=sqlite:///./data/leopost.db + - APP_URL=https://leopost.it + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - SECRET_KEY=${SECRET_KEY:-leopost-prod-secret-2026} + networks: + - proxy_net + deploy: + resources: + limits: + memory: 1024M + cpus: '1.0' + +networks: + proxy_net: + external: true diff --git a/docker-compose.yml b/docker-compose.yml index 12b7fe3..007e301 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,18 @@ services: app: - build: . + build: + context: . + dockerfile: Dockerfile + args: + VITE_BASE_PATH: "/leopost-full" + VITE_API_BASE: "/leopost-full/api" container_name: lab-leopost-full-app restart: unless-stopped volumes: - ./data:/app/data environment: - DATABASE_URL=sqlite:///./data/leopost.db + - APP_URL=https://lab.mlhub.it/leopost-full networks: - proxy_net deploy: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 741b188..1bdc921 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { AuthProvider } from './AuthContext' import ProtectedRoute from './components/ProtectedRoute' import Layout from './components/Layout' import LoginPage from './components/LoginPage' +import AuthCallback from './components/AuthCallback' import Dashboard from './components/Dashboard' import CharacterList from './components/CharacterList' import CharacterForm from './components/CharacterForm' @@ -17,13 +18,19 @@ import SocialAccounts from './components/SocialAccounts' import CommentsQueue from './components/CommentsQueue' import SettingsPage from './components/SettingsPage' import EditorialCalendar from './components/EditorialCalendar' +import AdminSettings from './components/AdminSettings' + +const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined + ? (import.meta.env.VITE_BASE_PATH || '/') + : '/leopost-full' export default function App() { return ( - + } /> + } /> }> }> } /> @@ -43,6 +50,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 1ee4164..1cbdc68 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -3,27 +3,81 @@ import { api } from './api' const AuthContext = createContext(null) +const PLAN_LIMITS = { + freemium: { + characters: 1, + posts: 15, + platforms: ['facebook', 'instagram'], + auto_plans: false, + comments_management: false, + affiliate_links: false, + }, + pro: { + characters: null, + posts: null, + platforms: ['facebook', 'instagram', 'youtube', 'tiktok'], + auto_plans: true, + comments_management: true, + affiliate_links: true, + }, +} + +function computeIsPro(user) { + if (!user) return false + if (user.subscription_plan !== 'pro') return false + if (user.subscription_expires_at) { + return new Date(user.subscription_expires_at) > new Date() + } + return true +} + export function AuthProvider({ children }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) + const loadUser = async () => { + try { + const data = await api.get('/auth/me') + setUser(data) + return data + } catch { + localStorage.removeItem('token') + setUser(null) + return null + } + } + useEffect(() => { const token = localStorage.getItem('token') if (token) { - api.get('/auth/me') - .then((data) => setUser(data)) - .catch(() => localStorage.removeItem('token')) - .finally(() => setLoading(false)) + loadUser().finally(() => setLoading(false)) } else { setLoading(false) } }, []) - const login = async (username, password) => { - const data = await api.post('/auth/login', { username, password }) + const login = async (emailOrUsername, password) => { + // Try email login first, fall back to username + const isEmail = emailOrUsername.includes('@') + const body = isEmail + ? { email: emailOrUsername, password } + : { username: emailOrUsername, password } + const data = await api.post('/auth/login', body) localStorage.setItem('token', data.access_token) - const me = await api.get('/auth/me') - setUser(me) + setUser(data.user) + return data.user + } + + const register = async (email, password, displayName) => { + const data = await api.post('/auth/register', { email, password, display_name: displayName }) + localStorage.setItem('token', data.access_token) + setUser(data.user) + return data.user + } + + const loginWithToken = (token) => { + localStorage.setItem('token', token) + loadUser() } const logout = () => { @@ -31,8 +85,23 @@ export function AuthProvider({ children }) { setUser(null) } + const isPro = computeIsPro(user) + const isAdmin = Boolean(user?.is_admin) + const planLimits = PLAN_LIMITS[isPro ? 'pro' : 'freemium'] + return ( - + {children} ) diff --git a/frontend/src/api.js b/frontend/src/api.js index 00dd36b..dcec6e2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,4 @@ -const BASE_URL = '/leopost-full/api' +const BASE_URL = import.meta.env.VITE_API_BASE || '/api' async function request(method, path, body = null) { const headers = { 'Content-Type': 'application/json' } @@ -13,13 +13,21 @@ async function request(method, path, body = null) { if (res.status === 401) { localStorage.removeItem('token') - window.location.href = '/leopost-full/login' + const basePath = import.meta.env.VITE_BASE_PATH || '/leopost-full' + window.location.href = basePath ? `${basePath}/login` : '/login' return } if (!res.ok) { const error = await res.json().catch(() => ({ detail: 'Request failed' })) - throw new Error(error.detail || 'Request failed') + // Pass through structured errors (upgrade_required etc.) + const detail = error.detail + if (typeof detail === 'object' && detail !== null) { + const err = new Error(detail.message || 'Request failed') + err.data = detail + throw err + } + throw new Error(detail || 'Request failed') } if (res.status === 204) return null @@ -32,3 +40,5 @@ export const api = { put: (path, body) => request('PUT', path, body), delete: (path) => request('DELETE', path), } + +export { BASE_URL } diff --git a/frontend/src/components/AdminSettings.jsx b/frontend/src/components/AdminSettings.jsx new file mode 100644 index 0000000..c9efa73 --- /dev/null +++ b/frontend/src/components/AdminSettings.jsx @@ -0,0 +1,344 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../api' +import { useAuth } from '../AuthContext' + +const INK = '#1A1A2E' +const MUTED = '#888' +const BORDER = '#E8E4DC' +const CORAL = '#FF6B4A' + +const DURATIONS = [ + { value: 1, label: '1 mese', price: '€14.95' }, + { value: 3, label: '3 mesi', price: '€39.95' }, + { value: 6, label: '6 mesi', price: '€64.95' }, + { value: 12, label: '12 mesi (1 anno)', price: '€119.95' }, +] + +export default function AdminSettings() { + const { isAdmin, user } = useAuth() + const navigate = useNavigate() + + const [users, setUsers] = useState([]) + const [codes, setCodes] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(true) + const [loadingCodes, setLoadingCodes] = useState(true) + const [duration, setDuration] = useState(1) + const [generating, setGenerating] = useState(false) + const [generatedCode, setGeneratedCode] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!isAdmin) { + navigate('/') + return + } + loadUsers() + loadCodes() + }, [isAdmin]) + + const loadUsers = async () => { + try { + const data = await api.get('/admin/users') + setUsers(data) + } catch { + // silently fail + } finally { + setLoadingUsers(false) + } + } + + const loadCodes = async () => { + try { + const data = await api.get('/admin/codes') + setCodes(data) + } catch { + // silently fail + } finally { + setLoadingCodes(false) + } + } + + const handleGenerateCode = async () => { + setGenerating(true) + setError('') + setGeneratedCode(null) + try { + const result = await api.post('/admin/codes/generate', { duration_months: duration }) + setGeneratedCode(result) + loadCodes() + } catch (err) { + setError(err.message || 'Errore nella generazione del codice.') + } finally { + setGenerating(false) + } + } + + const copyCode = async (code) => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // fallback + } + } + + if (!isAdmin) return null + + return ( +
+
+

+ Admin Settings +

+

+ Gestione utenti e codici abbonamento +

+
+ + {/* Generate Code Section */} +
+

+ Genera Codice Pro +

+ +
+
+ + +
+ +
+ + {error && ( +

{error}

+ )} + + {generatedCode && ( +
+

+ Codice generato ({DURATIONS.find(d => d.value === generatedCode.duration_months)?.label}) +

+
+ + {generatedCode.code} + + +
+
+ )} +
+ + {/* Codes Table */} +
+

+ Codici Generati +

+ {loadingCodes ? ( +

Caricamento...

+ ) : codes.length === 0 ? ( +

Nessun codice generato ancora.

+ ) : ( +
+ + + + {['Codice', 'Durata', 'Stato', 'Usato da', 'Data uso'].map((h) => ( + + ))} + + + + {codes.map((code) => ( + + + + + + + + ))} + +
+ {h} +
+ {code.code} + + {DURATIONS.find(d => d.value === code.duration_months)?.label || `${code.duration_months}m`} + + + {code.status === 'used' ? 'Usato' : 'Attivo'} + + + {code.used_by || '—'} + + {code.used_at ? new Date(code.used_at).toLocaleDateString('it-IT') : '—'} +
+
+ )} +
+ + {/* Users Table */} +
+

+ Utenti ({users.length}) +

+ {loadingUsers ? ( +

Caricamento...

+ ) : users.length === 0 ? ( +

Nessun utente.

+ ) : ( +
+ + + + {['Email / Username', 'Piano', 'Scadenza', 'Provider', 'Registrazione'].map((h) => ( + + ))} + + + + {users.map((u) => ( + + + + + + + + ))} + +
+ {h} +
+
{u.email || u.username}
+ {u.display_name && u.display_name !== u.email && ( +
{u.display_name}
+ )} + {u.is_admin && ( + + admin + + )} +
+ + {u.subscription_plan || 'freemium'} + + + {u.subscription_expires_at + ? new Date(u.subscription_expires_at).toLocaleDateString('it-IT') + : u.subscription_plan === 'pro' ? '∞' : '—'} + + {u.auth_provider || 'local'} + + {u.created_at ? new Date(u.created_at).toLocaleDateString('it-IT') : '—'} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/AuthCallback.jsx b/frontend/src/components/AuthCallback.jsx new file mode 100644 index 0000000..5dc2014 --- /dev/null +++ b/frontend/src/components/AuthCallback.jsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useAuth } from '../AuthContext' + +export default function AuthCallback() { + const [params] = useSearchParams() + const navigate = useNavigate() + const { loginWithToken } = useAuth() + + useEffect(() => { + const token = params.get('token') + if (token) { + loginWithToken(token) + navigate('/', { replace: true }) + } else { + navigate('/login', { replace: true }) + } + }, []) + + return ( +
+
+
+

Accesso in corso...

+
+ +
+ ) +} diff --git a/frontend/src/components/CharacterForm.jsx b/frontend/src/components/CharacterForm.jsx index 3c834e0..37f200c 100644 --- a/frontend/src/components/CharacterForm.jsx +++ b/frontend/src/components/CharacterForm.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' +import { useAuth } from '../AuthContext' const EMPTY_FORM = { name: '', @@ -11,17 +12,87 @@ const EMPTY_FORM = { is_active: true, } +const PLATFORMS = [ + { + id: 'facebook', + name: 'Facebook', + icon: '📘', + color: '#1877F2', + guide: [ + 'Vai su developers.facebook.com e accedi con il tuo account.', + 'Crea una nuova App → scegli "Business".', + 'Aggiungi il prodotto "Facebook Login" e "Pages API".', + 'In "Graph API Explorer", seleziona la tua app e la tua Pagina.', + 'Genera un Page Access Token con permessi: pages_manage_posts, pages_read_engagement.', + 'Copia il Page ID dalla pagina Facebook (Info → ID pagina).', + ], + proOnly: false, + }, + { + id: 'instagram', + name: 'Instagram', + icon: '📸', + color: '#E1306C', + guide: [ + 'Instagram usa le API di Facebook (Meta).', + 'Nella stessa app Meta, aggiungi il prodotto "Instagram Graph API".', + 'Collega un profilo Instagram Business alla tua pagina Facebook.', + 'In Graph API Explorer, genera un token con scope: instagram_basic, instagram_content_publish.', + 'Trova l\'Instagram User ID tramite: GET /{page-id}?fields=instagram_business_account.', + 'Inserisci il token e l\'IG User ID nei campi sottostanti.', + ], + proOnly: false, + }, + { + id: 'youtube', + name: 'YouTube', + icon: '▶️', + color: '#FF0000', + guide: [ + 'Vai su console.cloud.google.com e crea un progetto.', + 'Abilita "YouTube Data API v3" nella sezione API & Services.', + 'Crea credenziali OAuth 2.0 (tipo: Web application).', + 'Autorizza l\'accesso al tuo canale YouTube seguendo il flusso OAuth.', + 'Copia l\'Access Token e il Channel ID (visibile in YouTube Studio → Personalizzazione → Informazioni).', + ], + proOnly: true, + }, + { + id: 'tiktok', + name: 'TikTok', + icon: '🎵', + color: '#000000', + guide: [ + 'Vai su developers.tiktok.com e registra un account sviluppatore.', + 'Crea una nuova app → seleziona "Content Posting API".', + 'Richiedi i permessi: video.publish, video.upload.', + 'Completa il processo di verifica app (può richiedere alcuni giorni).', + 'Una volta approvata, genera un access token seguendo la documentazione OAuth 2.0.', + ], + proOnly: true, + }, +] + export default function CharacterForm() { const { id } = useParams() const isEdit = Boolean(id) const navigate = useNavigate() + const { isPro } = useAuth() + const [activeTab, setActiveTab] = useState('profile') const [form, setForm] = useState(EMPTY_FORM) const [topicInput, setTopicInput] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(isEdit) + // Social accounts state + const [socialAccounts, setSocialAccounts] = useState({}) + const [expandedGuide, setExpandedGuide] = useState(null) + const [savingToken, setSavingToken] = useState({}) + const [tokenInputs, setTokenInputs] = useState({}) + const [pageIdInputs, setPageIdInputs] = useState({}) + useEffect(() => { if (isEdit) { api.get(`/characters/${id}`) @@ -41,6 +112,15 @@ export default function CharacterForm() { }) .catch(() => setError('Personaggio non trovato')) .finally(() => setLoading(false)) + + // Load social accounts for this character + api.get(`/social/accounts?character_id=${id}`) + .then((accounts) => { + const map = {} + accounts.forEach((acc) => { map[acc.platform] = acc }) + setSocialAccounts(map) + }) + .catch(() => {}) } }, [id, isEdit]) @@ -89,12 +169,69 @@ export default function CharacterForm() { } navigate('/characters') } catch (err) { - setError(err.message || 'Errore nel salvataggio') + if (err.data?.upgrade_required) { + setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.') + } else { + setError(err.message || 'Errore nel salvataggio') + } } finally { setSaving(false) } } + const handleSaveToken = async (platform) => { + if (!isEdit) return + const token = tokenInputs[platform] || '' + const pageId = pageIdInputs[platform] || '' + if (!token.trim()) return + + setSavingToken((prev) => ({ ...prev, [platform]: true })) + try { + const existing = socialAccounts[platform] + if (existing) { + await api.put(`/social/accounts/${existing.id}`, { + access_token: token, + page_id: pageId || undefined, + }) + } else { + await api.post('/social/accounts', { + character_id: Number(id), + platform, + access_token: token, + page_id: pageId || undefined, + account_name: platform, + }) + } + // Reload + const accounts = await api.get(`/social/accounts?character_id=${id}`) + const map = {} + accounts.forEach((acc) => { map[acc.platform] = acc }) + setSocialAccounts(map) + setTokenInputs((prev) => ({ ...prev, [platform]: '' })) + setPageIdInputs((prev) => ({ ...prev, [platform]: '' })) + } catch (err) { + alert(err.message || 'Errore nel salvataggio del token.') + } finally { + setSavingToken((prev) => ({ ...prev, [platform]: false })) + } + } + + const handleDisconnect = async (platform) => { + const acc = socialAccounts[platform] + if (!acc) return + if (!window.confirm(`Disconnetti ${platform}?`)) return + try { + await api.delete(`/social/accounts/${acc.id}`) + setSocialAccounts((prev) => { + const next = { ...prev } + delete next[platform] + return next + }) + } catch (err) { + alert(err.message || 'Errore nella disconnessione.') + } + } + if (loading) { return (
@@ -114,218 +251,382 @@ export default function CharacterForm() {

-
- {error && ( -
- {error} -
- )} + {/* Tabs */} +
+ {[ + { id: 'profile', label: 'Profilo' }, + { id: 'social', label: 'Account Social', disabled: !isEdit }, + ].map((tab) => ( + + ))} +
- {/* Basic info */} -
-

- Informazioni base -

- -
- - handleChange('name', e.target.value)} - placeholder="Es. TechGuru, FoodBlogger..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- -
- - handleChange('niche', e.target.value)} - placeholder="Es. Tecnologia, Food, Fitness..." - className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm" - required - /> -
- -
- -