diff --git a/.env.example b/.env.example index a3fcb77..d8b66e5 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ DEV_AUTH_PASSWORD=devpass123 # LLM Provider — Claude Sonnet 4.6 for all text generation LLM=anthropic ANTHROPIC_API_KEY= -ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +ANTHROPIC_MODEL=claude-sonnet-4-6 # Image Provider — NanoBanana Pro for image generation GOOGLE_API_KEY= diff --git a/backend/Dockerfile b/backend/Dockerfile index aa23e30..0fdd935 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,5 +28,11 @@ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/pytho COPY --from=builder /usr/local/bin /usr/local/bin COPY . . +# Pre-download ONNX embedding model so it's baked into the image +RUN mkdir -p chroma/models && \ + curl -fsSL -o chroma/models/onnx.tar.gz \ + https://chroma-onnx-models.s3.amazonaws.com/all-MiniLM-L6-v2/onnx.tar.gz && \ + tar -xzf chroma/models/onnx.tar.gz -C chroma/models/ + EXPOSE 8000 CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/main.py b/backend/api/main.py index 5b7c491..3435f64 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -14,6 +14,8 @@ from api.v1.admin.audit_router import AUDIT_ROUTER from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER from api.v1.admin.analytics_router import ANALYTICS_ROUTER +from api.v1.admin.storage_router import STORAGE_ROUTER +from api.v1.admin.settings_router import SETTINGS_ROUTER from api.v1.ppt.endpoints.jobs import JOBS_ROUTER from api.v1.ppt.endpoints.review import REVIEW_ROUTER from api.v1.ppt.endpoints.export import EXPORT_ROUTER @@ -31,6 +33,8 @@ ADMIN_ROUTER.include_router(AUDIT_ROUTER) ADMIN_ROUTER.include_router(BRAND_CONFIG_ROUTER) ADMIN_ROUTER.include_router(MASTER_DECKS_ROUTER) ADMIN_ROUTER.include_router(ANALYTICS_ROUTER) +ADMIN_ROUTER.include_router(STORAGE_ROUTER) +ADMIN_ROUTER.include_router(SETTINGS_ROUTER) # Routers app.include_router(AUTH_ROUTER) diff --git a/backend/api/middlewares/auth_middleware.py b/backend/api/middlewares/auth_middleware.py index cb598dc..7496c59 100644 --- a/backend/api/middlewares/auth_middleware.py +++ b/backend/api/middlewares/auth_middleware.py @@ -8,9 +8,12 @@ from services.auth_service import AuthService from services.database import async_session_maker from models.sql.user import UserModel -# Paths that skip authentication -PUBLIC_PATH_PREFIXES = [ - "/api/v1/auth/", +# Paths that skip authentication (exact or prefix match) +PUBLIC_PATHS = [ + "/api/v1/auth/dev-status", + "/api/v1/auth/dev-login", + "/api/v1/auth/login", + "/api/v1/auth/callback", "/docs", "/openapi.json", "/api/health", @@ -24,8 +27,8 @@ class AuthMiddleware(BaseHTTPMiddleware): path = request.url.path # Skip auth for public paths - for prefix in PUBLIC_PATH_PREFIXES: - if path.startswith(prefix): + for public_path in PUBLIC_PATHS: + if path == public_path or path.startswith(public_path + "/"): request.state.user = None return await call_next(request) diff --git a/backend/api/v1/admin/analytics_router.py b/backend/api/v1/admin/analytics_router.py index c930a11..f4f2b00 100644 --- a/backend/api/v1/admin/analytics_router.py +++ b/backend/api/v1/admin/analytics_router.py @@ -4,19 +4,46 @@ from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, select, and_, case +from sqlalchemy import func, select, and_ from sqlalchemy.ext.asyncio import AsyncSession from models.sql.presentation import PresentationModel from models.sql.user import UserModel -from models.sql.audit_log import AuditLogModel from models.sql.job import JobModel +from services.access_service import get_accessible_client_ids from services.database import get_async_session from utils.auth_dependencies import require_client_admin ANALYTICS_ROUTER = APIRouter(tags=["Analytics"]) +async def _resolve_client_filter( + client_id: Optional[uuid.UUID], + user: UserModel, + session: AsyncSession, +): + """Return SQLAlchemy filter for presentation client scoping. + + super_admin with no filter → no restriction (sees all including NULL client_id). + client_admin → scoped to their accessible clients. + Explicit client_id → filter to that client only. + """ + if client_id: + return PresentationModel.client_id == client_id + + if user.role == "super_admin": + # Super admin sees everything — no client filter needed + return None + + cids = await get_accessible_client_ids(user, session) + if not cids: + # No accessible clients — return impossible condition + return PresentationModel.client_id == None # noqa: E711 + if len(cids) == 1: + return PresentationModel.client_id == cids[0] + return PresentationModel.client_id.in_(cids) + + @ANALYTICS_ROUTER.get("/analytics/overview") async def analytics_overview( client_id: Optional[uuid.UUID] = Query(None), @@ -28,10 +55,13 @@ async def analytics_overview( month_ago = now - timedelta(days=30) week_ago = now - timedelta(days=7) - # Base query filter - base = select(PresentationModel).where(PresentationModel.deleted_at.is_(None)) - if client_id: - base = base.where(PresentationModel.client_id == client_id) + cf = await _resolve_client_filter(client_id, current_user, session) + + base_filters = [PresentationModel.deleted_at.is_(None)] + if cf is not None: + base_filters.append(cf) + + base = select(PresentationModel).where(and_(*base_filters)) # Total presentations total_q = select(func.count()).select_from(base.subquery()) @@ -49,34 +79,34 @@ async def analytics_overview( ) this_week = (await session.execute(week_q)).scalar() or 0 - # Active users (users who created presentations in last 30 days) + # Active users (last 30 days) + active_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.created_at >= month_ago, + PresentationModel.owner_id.isnot(None), + ] + if cf is not None: + active_filters.append(cf) active_q = select(func.count(func.distinct(PresentationModel.owner_id))).where( - and_( - PresentationModel.deleted_at.is_(None), - PresentationModel.created_at >= month_ago, - PresentationModel.owner_id.isnot(None), - ) + and_(*active_filters) ) - if client_id: - active_q = active_q.where(PresentationModel.client_id == client_id) active_users = (await session.execute(active_q)).scalar() or 0 # Approval rate - reviewed_q = select(func.count()).where( - and_( - PresentationModel.deleted_at.is_(None), - PresentationModel.status.in_(["approved", "in_review"]), - ) - ) - approved_q = select(func.count()).where( - and_( - PresentationModel.deleted_at.is_(None), - PresentationModel.status == "approved", - ) - ) - if client_id: - reviewed_q = reviewed_q.where(PresentationModel.client_id == client_id) - approved_q = approved_q.where(PresentationModel.client_id == client_id) + reviewed_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.status.in_(["approved", "in_review"]), + ] + if cf is not None: + reviewed_filters.append(cf) + reviewed_q = select(func.count()).where(and_(*reviewed_filters)) + approved_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.status == "approved", + ] + if cf is not None: + approved_filters.append(cf) + approved_q = select(func.count()).where(and_(*approved_filters)) reviewed = (await session.execute(reviewed_q)).scalar() or 0 approved = (await session.execute(approved_q)).scalar() or 0 approval_rate = round((approved / reviewed * 100) if reviewed > 0 else 0, 1) @@ -101,45 +131,45 @@ async def analytics_usage( now = datetime.now(timezone.utc) since = now - timedelta(days=days) + cf = await _resolve_client_filter(client_id, current_user, session) + # Decks per day + daily_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.created_at >= since, + ] + if cf is not None: + daily_filters.append(cf) daily_q = ( select( func.date(PresentationModel.created_at).label("day"), func.count().label("count"), ) - .where( - and_( - PresentationModel.deleted_at.is_(None), - PresentationModel.created_at >= since, - ) - ) + .where(and_(*daily_filters)) .group_by(func.date(PresentationModel.created_at)) .order_by(func.date(PresentationModel.created_at)) ) - if client_id: - daily_q = daily_q.where(PresentationModel.client_id == client_id) daily_result = await session.execute(daily_q) daily = [{"date": str(row.day), "count": row.count} for row in daily_result] # Top users + top_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.owner_id.isnot(None), + PresentationModel.created_at >= since, + ] + if cf is not None: + top_filters.append(cf) top_users_q = ( select( PresentationModel.owner_id, func.count().label("count"), ) - .where( - and_( - PresentationModel.deleted_at.is_(None), - PresentationModel.owner_id.isnot(None), - PresentationModel.created_at >= since, - ) - ) + .where(and_(*top_filters)) .group_by(PresentationModel.owner_id) .order_by(func.count().desc()) .limit(10) ) - if client_id: - top_users_q = top_users_q.where(PresentationModel.client_id == client_id) top_result = await session.execute(top_users_q) top_users = [ {"user_id": str(row.owner_id), "count": row.count} for row in top_result @@ -158,9 +188,11 @@ async def analytics_quality( session: AsyncSession = Depends(get_async_session), ): """Quality metrics: status distribution, average comments.""" + cf = await _resolve_client_filter(client_id, current_user, session) + base_filter = [PresentationModel.deleted_at.is_(None)] - if client_id: - base_filter.append(PresentationModel.client_id == client_id) + if cf is not None: + base_filter.append(cf) # Status distribution status_q = ( @@ -196,11 +228,17 @@ async def analytics_performance( session: AsyncSession = Depends(get_async_session), ): """Performance metrics: job durations and error rates.""" - base_filter = [] + # Build job filter + job_filter = [] if client_id: - # Jobs don't have client_id directly, so we join through presentations - # For simplicity, return unfiltered job stats - pass + job_filter.append(JobModel.client_id == client_id) + elif current_user.role != "super_admin": + cids = await get_accessible_client_ids(current_user, session) + if cids: + if len(cids) == 1: + job_filter.append(JobModel.client_id == cids[0]) + else: + job_filter.append(JobModel.client_id.in_(cids)) # Job status distribution job_status_q = ( @@ -208,24 +246,29 @@ async def analytics_performance( JobModel.status, func.count().label("count"), ) - .group_by(JobModel.status) - ) + .where(and_(*job_filter)) if job_filter else + select( + JobModel.status, + func.count().label("count"), + ) + ).group_by(JobModel.status) job_result = await session.execute(job_status_q) job_status = {row.status: row.count for row in job_result} # Average generation time (completed jobs only) + avg_base = [ + JobModel.status == "completed", + JobModel.started_at.isnot(None), + JobModel.completed_at.isnot(None), + ] + if job_filter: + avg_base.extend(job_filter) avg_time_q = select( func.avg( func.extract("epoch", JobModel.completed_at) - func.extract("epoch", JobModel.started_at) ) - ).where( - and_( - JobModel.status == "completed", - JobModel.started_at.isnot(None), - JobModel.completed_at.isnot(None), - ) - ) + ).where(and_(*avg_base)) avg_seconds = (await session.execute(avg_time_q)).scalar() avg_generation_time = round(avg_seconds, 1) if avg_seconds else None @@ -240,3 +283,97 @@ async def analytics_performance( "error_rate": error_rate, "total_jobs": total_jobs, } + + +@ANALYTICS_ROUTER.get("/analytics/ai-usage") +async def analytics_ai_usage( + client_id: Optional[uuid.UUID] = Query(None), + days: int = Query(30, ge=7, le=90), + current_user: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """AI model usage: total calls, tokens consumed, breakdown by provider.""" + from models.sql.ai_usage import AIUsageModel + + since = datetime.now(timezone.utc) - timedelta(days=days) + + base_filter = [AIUsageModel.created_at >= since] + if client_id: + base_filter.append(AIUsageModel.client_id == client_id) + elif current_user.role != "super_admin": + cids = await get_accessible_client_ids(current_user, session) + if cids: + if len(cids) == 1: + base_filter.append(AIUsageModel.client_id == cids[0]) + else: + base_filter.append(AIUsageModel.client_id.in_(cids)) + + # Totals + totals_q = select( + func.count().label("total_calls"), + func.coalesce(func.sum(AIUsageModel.input_tokens), 0).label("total_input_tokens"), + func.coalesce(func.sum(AIUsageModel.output_tokens), 0).label("total_output_tokens"), + func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("total_tokens"), + ).where(and_(*base_filter)) + totals = (await session.execute(totals_q)).one() + + # By provider + by_provider_q = ( + select( + AIUsageModel.provider, + func.count().label("calls"), + func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), + ) + .where(and_(*base_filter)) + .group_by(AIUsageModel.provider) + ) + provider_result = await session.execute(by_provider_q) + by_provider = [ + {"provider": row.provider, "calls": row.calls, "tokens": row.tokens} + for row in provider_result + ] + + # By model + by_model_q = ( + select( + AIUsageModel.model, + func.count().label("calls"), + func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), + ) + .where(and_(*base_filter)) + .group_by(AIUsageModel.model) + .order_by(func.count().desc()) + .limit(10) + ) + model_result = await session.execute(by_model_q) + by_model = [ + {"model": row.model, "calls": row.calls, "tokens": row.tokens} + for row in model_result + ] + + # Daily usage + daily_q = ( + select( + func.date(AIUsageModel.created_at).label("day"), + func.count().label("calls"), + func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), + ) + .where(and_(*base_filter)) + .group_by(func.date(AIUsageModel.created_at)) + .order_by(func.date(AIUsageModel.created_at)) + ) + daily_result = await session.execute(daily_q) + daily = [ + {"date": str(row.day), "calls": row.calls, "tokens": row.tokens} + for row in daily_result + ] + + return { + "total_calls": totals.total_calls, + "total_input_tokens": totals.total_input_tokens, + "total_output_tokens": totals.total_output_tokens, + "total_tokens": totals.total_tokens, + "by_provider": by_provider, + "by_model": by_model, + "daily": daily, + } diff --git a/backend/api/v1/admin/brand_config_router.py b/backend/api/v1/admin/brand_config_router.py index 22a7e2b..68470f9 100644 --- a/backend/api/v1/admin/brand_config_router.py +++ b/backend/api/v1/admin/brand_config_router.py @@ -35,7 +35,17 @@ async def get_brand_config( ) config = result.scalar_one_or_none() if not config: - raise HTTPException(status_code=404, detail="Brand config not found") + return { + "id": None, + "client_id": str(client_id), + "primary_colors": None, + "secondary_colors": None, + "fonts": None, + "logo_paths": None, + "voice_rules": None, + "voice_examples": None, + "guideline_doc_path": None, + } return { "id": str(config.id), diff --git a/backend/api/v1/admin/master_decks_router.py b/backend/api/v1/admin/master_decks_router.py index bf57503..a7b149c 100644 --- a/backend/api/v1/admin/master_decks_router.py +++ b/backend/api/v1/admin/master_decks_router.py @@ -5,6 +5,7 @@ import uuid from typing import Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,7 +19,7 @@ from utils.auth_dependencies import require_client_admin MASTER_DECKS_ROUTER = APIRouter(tags=["Admin - Master Decks"]) -DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data") +DATA_DIR = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data")) def _deck_dir(client_id: uuid.UUID, deck_id: uuid.UUID) -> str: @@ -40,18 +41,10 @@ class LayoutUpdate(BaseModel): react_code: Optional[str] = None -# --- Endpoints --- +# --- Helpers --- -@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks") -async def list_master_decks( - client_id: uuid.UUID, - include_inactive: bool = Query(False), - admin: UserModel = Depends(require_client_admin), - session: AsyncSession = Depends(get_async_session), -): - await check_team_admin(admin, client_id, session) - +async def _list_decks(client_id: uuid.UUID, include_inactive: bool, session: AsyncSession): stmt = select(MasterDeckModel).where(MasterDeckModel.client_id == client_id) if not include_inactive: stmt = stmt.where(MasterDeckModel.is_active == True) @@ -77,6 +70,31 @@ async def list_master_decks( ] +# --- Endpoints --- + + +@MASTER_DECKS_ROUTER.get("/master-decks") +async def list_master_decks_flat( + client_id: uuid.UUID = Query(...), + include_inactive: bool = Query(False), + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + await check_team_admin(admin, client_id, session) + return await _list_decks(client_id, include_inactive, session) + + +@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks") +async def list_master_decks( + client_id: uuid.UUID, + include_inactive: bool = Query(False), + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + await check_team_admin(admin, client_id, session) + return await _list_decks(client_id, include_inactive, session) + + @MASTER_DECKS_ROUTER.post("/clients/{client_id}/master-decks") async def upload_master_deck( client_id: uuid.UUID, @@ -86,7 +104,9 @@ async def upload_master_deck( ): await check_team_admin(admin, client_id, session) - if file.content_type not in POWERPOINT_TYPES: + is_pptx_mime = file.content_type in POWERPOINT_TYPES + is_pptx_ext = (file.filename or "").lower().endswith(".pptx") + if not is_pptx_mime and not is_pptx_ext: raise HTTPException(status_code=400, detail="Only PPTX files are accepted") if hasattr(file, "size") and file.size and file.size > 100 * 1024 * 1024: @@ -122,7 +142,7 @@ async def upload_master_deck( job = JobModel( user_id=admin.id, client_id=client_id, - presentation_id=deck_id, # reuse field for deck_id + presentation_id=None, job_type="parse_master_deck", status="queued", progress=0, @@ -130,7 +150,7 @@ async def upload_master_deck( ) session.add(job) await session.commit() - await enqueue_job("parse_master_deck_task", job_id=str(job.id)) + await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id)) except Exception: import asyncio from services.master_deck_parser_service import parse_master_deck @@ -258,7 +278,7 @@ async def reparse_master_deck( job = JobModel( user_id=admin.id, client_id=deck.client_id, - presentation_id=deck_id, + presentation_id=None, job_type="parse_master_deck", status="queued", progress=0, @@ -266,8 +286,9 @@ async def reparse_master_deck( ) session.add(job) await session.commit() - await enqueue_job("parse_master_deck_task", job_id=str(job.id)) - except Exception: + await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id)) + except Exception as e: + print(f"[reparse] Failed to enqueue job, falling back to async: {e}") import asyncio from services.master_deck_parser_service import parse_master_deck asyncio.create_task(parse_master_deck(deck_id)) @@ -292,3 +313,31 @@ async def delete_master_deck( await session.commit() return {"ok": True} + + +@MASTER_DECKS_ROUTER.get("/master-decks/{deck_id}/screenshot/{filename}") +async def get_master_deck_screenshot( + deck_id: uuid.UUID, + filename: str, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Serve a master deck screenshot image with auth check.""" + deck = await session.get(MasterDeckModel, deck_id) + if not deck: + raise HTTPException(status_code=404, detail="Master deck not found") + + await check_team_admin(admin, deck.client_id, session) + + # Sanitize filename to prevent path traversal + safe_name = os.path.basename(filename) + screenshot_dir = os.path.join( + DATA_DIR, "clients", str(deck.client_id), + "master_decks", str(deck_id), "screenshots" + ) + file_path = os.path.join(screenshot_dir, safe_name) + + if not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Screenshot not found") + + return FileResponse(file_path, media_type="image/png") diff --git a/backend/api/v1/admin/settings_router.py b/backend/api/v1/admin/settings_router.py new file mode 100644 index 0000000..dabdaf5 --- /dev/null +++ b/backend/api/v1/admin/settings_router.py @@ -0,0 +1,128 @@ +"""Admin router for system settings — LLM and image provider configuration.""" +import os +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.user import UserModel +from services.database import get_async_session +from utils.auth_dependencies import require_client_admin + +SETTINGS_ROUTER = APIRouter(tags=["Admin - Settings"]) + + +class SystemSettings(BaseModel): + llm_provider: Optional[str] = None + llm_model: Optional[str] = None + image_provider: Optional[str] = None + anthropic_api_key_set: bool = False + openai_api_key_set: bool = False + google_api_key_set: bool = False + + +class SettingsUpdate(BaseModel): + llm_provider: Optional[str] = None + llm_model: Optional[str] = None + image_provider: Optional[str] = None + anthropic_api_key: Optional[str] = None + openai_api_key: Optional[str] = None + google_api_key: Optional[str] = None + + +LLM_PROVIDERS = ["anthropic", "openai", "google", "ollama", "custom"] +IMAGE_PROVIDERS = ["google", "dall-e-3", "gpt-image-1.5", "pexels", "pixabay", "comfyui"] + + +@SETTINGS_ROUTER.get("/settings") +async def get_settings( + admin: UserModel = Depends(require_client_admin), + _session: AsyncSession = Depends(get_async_session), +): + """Return current system settings (env-based config).""" + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + return { + "llm_provider": os.getenv("LLM", "anthropic"), + "llm_model": _get_current_model(), + "image_provider": os.getenv("IMAGE_PROVIDER", "google"), + "anthropic_api_key_set": bool(os.getenv("ANTHROPIC_API_KEY")), + "openai_api_key_set": bool(os.getenv("OPENAI_API_KEY")), + "google_api_key_set": bool(os.getenv("GOOGLE_API_KEY")), + "available_llm_providers": LLM_PROVIDERS, + "available_image_providers": IMAGE_PROVIDERS, + } + + +@SETTINGS_ROUTER.put("/settings") +async def update_settings( + body: SettingsUpdate, + admin: UserModel = Depends(require_client_admin), + _session: AsyncSession = Depends(get_async_session), +): + """Update system settings (runtime env vars). Changes don't persist across restarts.""" + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + changed = [] + + if body.llm_provider is not None: + if body.llm_provider not in LLM_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Invalid LLM provider: {body.llm_provider}") + os.environ["LLM"] = body.llm_provider + changed.append("llm_provider") + + if body.llm_model is not None: + provider = body.llm_provider or os.getenv("LLM", "anthropic") + model_env_map = { + "anthropic": "ANTHROPIC_MODEL", + "openai": "OPENAI_MODEL", + "google": "GOOGLE_MODEL", + "ollama": "OLLAMA_MODEL", + "custom": "CUSTOM_MODEL", + } + env_key = model_env_map.get(provider) + if env_key: + os.environ[env_key] = body.llm_model + changed.append("llm_model") + + if body.image_provider is not None: + if body.image_provider not in IMAGE_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Invalid image provider: {body.image_provider}") + os.environ["IMAGE_PROVIDER"] = body.image_provider + changed.append("image_provider") + + if body.anthropic_api_key is not None: + os.environ["ANTHROPIC_API_KEY"] = body.anthropic_api_key + changed.append("anthropic_api_key") + + if body.openai_api_key is not None: + os.environ["OPENAI_API_KEY"] = body.openai_api_key + changed.append("openai_api_key") + + if body.google_api_key is not None: + os.environ["GOOGLE_API_KEY"] = body.google_api_key + changed.append("google_api_key") + + return {"ok": True, "changed": changed} + + +def _get_current_model() -> str: + provider = os.getenv("LLM", "anthropic") + model_env_map = { + "anthropic": "ANTHROPIC_MODEL", + "openai": "OPENAI_MODEL", + "google": "GOOGLE_MODEL", + "ollama": "OLLAMA_MODEL", + "custom": "CUSTOM_MODEL", + } + from constants.llm import DEFAULT_ANTHROPIC_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_GOOGLE_MODEL + defaults = { + "anthropic": DEFAULT_ANTHROPIC_MODEL, + "openai": DEFAULT_OPENAI_MODEL, + "google": DEFAULT_GOOGLE_MODEL, + } + env_key = model_env_map.get(provider, "ANTHROPIC_MODEL") + return os.getenv(env_key, defaults.get(provider, "")) diff --git a/backend/api/v1/admin/storage_router.py b/backend/api/v1/admin/storage_router.py new file mode 100644 index 0000000..e5d80ee --- /dev/null +++ b/backend/api/v1/admin/storage_router.py @@ -0,0 +1,191 @@ +"""Admin router for storage management — list, download, delete presentations.""" +import os +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from sqlalchemy import func, select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.presentation import PresentationModel +from models.sql.user import UserModel +from services import audit_service +from services.access_service import get_accessible_client_ids +from services.database import get_async_session +from utils.auth_dependencies import require_client_admin +from utils.datetime_utils import get_current_utc_datetime + +STORAGE_ROUTER = APIRouter(tags=["Admin - Storage"]) + + +async def _resolve_client_filter(client_id, user, session): + """Return SQLAlchemy filter or None (super_admin sees all).""" + if client_id: + return PresentationModel.client_id == client_id + if user.role == "super_admin": + return None + cids = await get_accessible_client_ids(user, session) + if not cids: + return PresentationModel.client_id == None # noqa: E711 + if len(cids) == 1: + return PresentationModel.client_id == cids[0] + return PresentationModel.client_id.in_(cids) + + +@STORAGE_ROUTER.get("/storage/summary") +async def storage_summary( + client_id: Optional[uuid.UUID] = Query(None), + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Storage summary: total presentations, files, and disk usage.""" + cf = await _resolve_client_filter(client_id, admin, session) + + filters = [PresentationModel.deleted_at.is_(None)] + if cf is not None: + filters.append(cf) + + # Count presentations + count_q = select(func.count()).where(and_(*filters)) + total_presentations = (await session.execute(count_q)).scalar() or 0 + + # Get all file_paths to compute size + path_filters = [ + PresentationModel.deleted_at.is_(None), + PresentationModel.file_paths.isnot(None), + ] + if cf is not None: + path_filters.append(cf) + paths_q = select(PresentationModel.file_paths).where(and_(*path_filters)) + result = await session.execute(paths_q) + all_paths = result.scalars().all() + + total_files = 0 + total_size_bytes = 0 + for file_paths in all_paths: + if not file_paths: + continue + for path in file_paths: + if path and os.path.isfile(path): + total_files += 1 + total_size_bytes += os.path.getsize(path) + + return { + "total_presentations": total_presentations, + "total_files": total_files, + "total_size_bytes": total_size_bytes, + } + + +@STORAGE_ROUTER.get("/storage/presentations") +async def list_storage_presentations( + client_id: Optional[uuid.UUID] = Query(None), + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """List presentations with file metadata for storage management.""" + cf = await _resolve_client_filter(client_id, admin, session) + + filters = [PresentationModel.deleted_at.is_(None)] + if cf is not None: + filters.append(cf) + + stmt = ( + select(PresentationModel) + .where(and_(*filters)) + .order_by(PresentationModel.created_at.desc()) + ) + result = await session.execute(stmt) + presentations = result.scalars().all() + + items = [] + for p in presentations: + file_count = 0 + total_size = 0 + if p.file_paths: + for path in p.file_paths: + if path and os.path.isfile(path): + file_count += 1 + total_size += os.path.getsize(path) + + items.append({ + "id": str(p.id), + "title": p.title, + "status": p.status, + "created_at": p.created_at.isoformat() if p.created_at else None, + "file_count": file_count, + "total_size_bytes": total_size, + "has_export": bool(p.file_paths and any( + fp.endswith(".pptx") for fp in p.file_paths if fp + )), + }) + + return items + + +@STORAGE_ROUTER.get("/storage/presentations/{presentation_id}/download") +async def download_presentation( + presentation_id: uuid.UUID, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Download the PPTX export file for a presentation.""" + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + # Verify access + if presentation.client_id: + cids = await get_accessible_client_ids(admin, session) + if presentation.client_id not in cids: + raise HTTPException(status_code=403, detail="Access denied") + + if not presentation.file_paths: + raise HTTPException(status_code=404, detail="No export files available") + + # Find the PPTX file + pptx_path = next( + (p for p in presentation.file_paths if p and p.endswith(".pptx") and os.path.isfile(p)), + None, + ) + if not pptx_path: + raise HTTPException(status_code=404, detail="PPTX file not found on disk") + + filename = f"{presentation.title or 'presentation'}.pptx" + return FileResponse( + pptx_path, + filename=filename, + media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + +@STORAGE_ROUTER.delete("/storage/presentations/{presentation_id}") +async def delete_presentation_storage( + presentation_id: uuid.UUID, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Soft-delete a presentation (files cleaned up by retention service).""" + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + # Verify access + if presentation.client_id: + cids = await get_accessible_client_ids(admin, session) + if presentation.client_id not in cids: + raise HTTPException(status_code=403, detail="Access denied") + + presentation.deleted_at = get_current_utc_datetime() + await session.commit() + + audit_service.log( + user_id=admin.id, + action="admin_delete", + resource_type="presentation", + resource_id=presentation.id, + client_id=presentation.client_id, + ) + + return {"ok": True} diff --git a/backend/api/v1/auth/router.py b/backend/api/v1/auth/router.py index ed7acc4..b28776a 100644 --- a/backend/api/v1/auth/router.py +++ b/backend/api/v1/auth/router.py @@ -61,7 +61,7 @@ async def callback( user = await auth_service.get_or_create_user(claims, session) token = auth_service.create_session_jwt(user) - response = RedirectResponse(url="/upload", status_code=302) + response = RedirectResponse(url="/dashboard", status_code=302) response.set_cookie( key="session_token", value=token, @@ -112,17 +112,26 @@ async def dev_login( @AUTH_ROUTER.get("/me") -async def get_current_user_info(request: Request): +async def get_current_user_info( + request: Request, + session: AsyncSession = Depends(get_async_session), +): """Return current authenticated user info.""" user = getattr(request.state, "user", None) if not user: raise HTTPException(status_code=401, detail="Not authenticated") + from services.access_service import get_accessible_client_ids + + client_ids = await get_accessible_client_ids(user, session) + primary_client_id = str(client_ids[0]) if client_ids else None + return { "id": str(user.id), "email": user.email, "displayName": user.display_name, "role": user.role, + "clientId": primary_client_id, } diff --git a/backend/constants/llm.py b/backend/constants/llm.py index 7d374f3..24a5373 100644 --- a/backend/constants/llm.py +++ b/backend/constants/llm.py @@ -3,4 +3,4 @@ OPENAI_URL = "https://api.openai.com/v1" # Default models DEFAULT_OPENAI_MODEL = "gpt-4.1" DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash" -DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514" +DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6" diff --git a/backend/migrations/env.py b/backend/migrations/env.py index b02889b..e4eeef3 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -27,6 +27,7 @@ from models.sql.brand_config import BrandConfigModel # noqa: F401 from models.sql.master_deck import MasterDeckModel # noqa: F401 from models.sql.audit_log import AuditLogModel # noqa: F401 from models.sql.job import JobModel # noqa: F401 +from models.sql.ai_usage import AIUsageModel # noqa: F401 from utils.db_utils import get_database_url_and_connect_args diff --git a/backend/migrations/versions/513b48dbb16c_add_ai_usage_logs_table.py b/backend/migrations/versions/513b48dbb16c_add_ai_usage_logs_table.py new file mode 100644 index 0000000..c3c3640 --- /dev/null +++ b/backend/migrations/versions/513b48dbb16c_add_ai_usage_logs_table.py @@ -0,0 +1,48 @@ +"""add_ai_usage_logs_table + +Revision ID: 513b48dbb16c +Revises: 0a8788565a3e +Create Date: 2026-02-26 22:11:20.453662 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '513b48dbb16c' +down_revision: Union[str, None] = '0a8788565a3e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ai_usage_logs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('client_id', sa.Uuid(), nullable=True), + sa.Column('presentation_id', sa.Uuid(), nullable=True), + sa.Column('provider', sa.String(), nullable=False), + sa.Column('model', sa.String(), nullable=False), + sa.Column('call_type', sa.String(), nullable=False), + sa.Column('input_tokens', sa.Integer(), nullable=True), + sa.Column('output_tokens', sa.Integer(), nullable=True), + sa.Column('total_tokens', sa.Integer(), nullable=True), + sa.Column('duration_ms', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['presentation_id'], ['presentations.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ai_usage_logs') + # ### end Alembic commands ### diff --git a/backend/models/sql/ai_usage.py b/backend/models/sql/ai_usage.py new file mode 100644 index 0000000..e49a4d1 --- /dev/null +++ b/backend/models/sql/ai_usage.py @@ -0,0 +1,36 @@ +"""AI usage tracking model for analytics.""" +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlmodel import Field, SQLModel + +from utils.datetime_utils import get_current_utc_datetime + + +class AIUsageModel(SQLModel, table=True): + __tablename__ = "ai_usage_logs" + + id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4) + user_id: Optional[uuid.UUID] = Field( + sa_column=Column(ForeignKey("users.id"), nullable=True), default=None + ) + client_id: Optional[uuid.UUID] = Field( + sa_column=Column(ForeignKey("clients.id"), nullable=True), default=None + ) + presentation_id: Optional[uuid.UUID] = Field( + sa_column=Column(ForeignKey("presentations.id"), nullable=True), default=None + ) + provider: str = Field(sa_column=Column(String, nullable=False)) + model: str = Field(sa_column=Column(String, nullable=False)) + call_type: str = Field(sa_column=Column(String, nullable=False)) + input_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) + output_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) + total_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) + duration_ms: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), nullable=False, default=get_current_utc_datetime + ), + ) diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index 302e484..82f7598 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -1,4 +1,4 @@ -"""Seed the database with the default Oliver Team.""" +"""Seed the database with the default Oliver Team and admin user.""" import asyncio import uuid from datetime import datetime, timezone @@ -8,32 +8,70 @@ from services.database import async_session_maker # Import all models so SQLAlchemy resolves FK references from models.sql.client import ClientModel # noqa: F401 -from models.sql.user import UserModel # noqa: F401 +from models.sql.user import UserModel from models.sql.team import TeamModel -from models.sql.team_membership import TeamMembershipModel # noqa: F401 +from models.sql.team_membership import TeamMembershipModel async def seed(): async with async_session_maker() as session: + # --- Oliver Team --- result = await session.execute( select(TeamModel).where(TeamModel.is_default == True) # noqa: E712 ) - existing = result.scalar_one_or_none() + oliver_team = result.scalar_one_or_none() - if existing: - print(f"Oliver Team already exists (id: {existing.id}), skipping seed.") - return + if oliver_team: + print(f"Oliver Team already exists (id: {oliver_team.id})") + else: + oliver_team = TeamModel( + id=uuid.uuid4(), + name="Oliver Team", + client_id=None, + is_default=True, + created_at=datetime.now(timezone.utc), + ) + session.add(oliver_team) + await session.flush() + print(f"Created Oliver Team with id: {oliver_team.id}") - oliver_team = TeamModel( - id=uuid.uuid4(), - name="Oliver Team", - client_id=None, - is_default=True, - created_at=datetime.now(timezone.utc), + # --- Admin user --- + admin_email = "admin@deckforge.dev" + result = await session.execute( + select(UserModel).where(UserModel.email == admin_email) ) - session.add(oliver_team) + admin_user = result.scalar_one_or_none() + + if admin_user: + if admin_user.role != "super_admin": + admin_user.role = "super_admin" + session.add(admin_user) + print(f"Updated {admin_email} role to super_admin") + else: + print(f"Admin user already exists (id: {admin_user.id})") + else: + admin_user = UserModel( + email=admin_email, + display_name="Admin", + role="super_admin", + is_active=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add(admin_user) + await session.flush() + print(f"Created admin user with id: {admin_user.id}") + + # Add to Oliver Team + membership = TeamMembershipModel( + user_id=admin_user.id, + team_id=oliver_team.id, + assigned_at=datetime.now(timezone.utc), + ) + session.add(membership) + await session.commit() - print(f"Created Oliver Team with id: {oliver_team.id}") + print("Seed complete.") if __name__ == "__main__": diff --git a/backend/services/ai_usage_service.py b/backend/services/ai_usage_service.py new file mode 100644 index 0000000..d55d572 --- /dev/null +++ b/backend/services/ai_usage_service.py @@ -0,0 +1,80 @@ +"""Fire-and-forget AI usage logging service.""" +import asyncio +import uuid +from typing import Optional + +from services.database import async_session_maker +from utils.llm_context import get_llm_context + + +def log( + provider: str, + model: str, + call_type: str, + input_tokens: Optional[int] = None, + output_tokens: Optional[int] = None, + total_tokens: Optional[int] = None, + duration_ms: Optional[int] = None, + user_id: Optional[uuid.UUID] = None, + client_id: Optional[uuid.UUID] = None, + presentation_id: Optional[uuid.UUID] = None, +) -> None: + """Log AI usage asynchronously (fire-and-forget). + + If user_id/client_id/presentation_id are not provided, + reads them from the LLMCallContext contextvar. + """ + ctx = get_llm_context() + if ctx: + user_id = user_id or ctx.user_id + client_id = client_id or ctx.client_id + presentation_id = presentation_id or ctx.presentation_id + + asyncio.create_task( + _write_log( + provider=provider, + model=model, + call_type=call_type, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + duration_ms=duration_ms, + user_id=user_id, + client_id=client_id, + presentation_id=presentation_id, + ) + ) + + +async def _write_log( + provider: str, + model: str, + call_type: str, + input_tokens: Optional[int], + output_tokens: Optional[int], + total_tokens: Optional[int], + duration_ms: Optional[int], + user_id: Optional[uuid.UUID], + client_id: Optional[uuid.UUID], + presentation_id: Optional[uuid.UUID], +) -> None: + try: + from models.sql.ai_usage import AIUsageModel + + async with async_session_maker() as session: + entry = AIUsageModel( + provider=provider, + model=model, + call_type=call_type, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens or ((input_tokens or 0) + (output_tokens or 0)), + duration_ms=duration_ms, + user_id=user_id, + client_id=client_id, + presentation_id=presentation_id, + ) + session.add(entry) + await session.commit() + except Exception as e: + print(f"AI usage log error: {e}") diff --git a/backend/services/llm_client.py b/backend/services/llm_client.py index cf4a5d7..6150890 100644 --- a/backend/services/llm_client.py +++ b/backend/services/llm_client.py @@ -264,6 +264,15 @@ class LLMClient: depth=depth + 1, ) + # Log AI usage + from services import ai_usage_service + usage = getattr(response, "usage", None) + ai_usage_service.log( + provider="openai", model=model, call_type="generate", + input_tokens=getattr(usage, "prompt_tokens", None) if usage else None, + output_tokens=getattr(usage, "completion_tokens", None) if usage else None, + total_tokens=getattr(usage, "total_tokens", None) if usage else None, + ) return response.choices[0].message.content async def _generate_google( @@ -332,6 +341,15 @@ class LLMClient: depth=depth + 1, ) + # Log AI usage + from services import ai_usage_service + usage_meta = getattr(response, "usage_metadata", None) + ai_usage_service.log( + provider="google", model=model, call_type="generate", + input_tokens=getattr(usage_meta, "prompt_token_count", None) if usage_meta else None, + output_tokens=getattr(usage_meta, "candidates_token_count", None) if usage_meta else None, + total_tokens=getattr(usage_meta, "total_token_count", None) if usage_meta else None, + ) return text_content async def _generate_anthropic( @@ -393,6 +411,13 @@ class LLMClient: depth=depth + 1, ) + # Log AI usage + from services import ai_usage_service + ai_usage_service.log( + provider="anthropic", model=model, call_type="generate", + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + ) return text_content async def _generate_ollama( @@ -619,7 +644,16 @@ class LLMClient: depth=depth + 1, ) if content: + # Log AI usage at the top-level call if depth == 0: + from services import ai_usage_service + usage = getattr(response, "usage", None) + ai_usage_service.log( + provider="openai", model=model, call_type="generate_structured", + input_tokens=getattr(usage, "prompt_tokens", None) if usage else None, + output_tokens=getattr(usage, "completion_tokens", None) if usage else None, + total_tokens=getattr(usage, "total_tokens", None) if usage else None, + ) return dict(dirtyjson.loads(content)) return content return None @@ -721,6 +755,15 @@ class LLMClient: ) if text_content: + # Log AI usage + from services import ai_usage_service + usage_meta = getattr(response, "usage_metadata", None) + ai_usage_service.log( + provider="google", model=model, call_type="generate_structured", + input_tokens=getattr(usage_meta, "prompt_token_count", None) if usage_meta else None, + output_tokens=getattr(usage_meta, "candidates_token_count", None) if usage_meta else None, + total_tokens=getattr(usage_meta, "total_token_count", None) if usage_meta else None, + ) return dict(dirtyjson.loads(text_content)) return None @@ -765,6 +808,13 @@ class LLMClient: for each in tool_calls: if each.name == "ResponseSchema": + # Log AI usage + from services import ai_usage_service + ai_usage_service.log( + provider="anthropic", model=model, call_type="generate_structured", + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + ) return each.input if tool_calls: diff --git a/backend/services/master_deck_parser_service.py b/backend/services/master_deck_parser_service.py index ffee6b0..465ace8 100644 --- a/backend/services/master_deck_parser_service.py +++ b/backend/services/master_deck_parser_service.py @@ -28,9 +28,9 @@ from api.v1.ppt.endpoints.pptx_slides import ( extract_fonts_from_oxml, normalize_font_family_name, ) -from api.v1.ppt.endpoints.slide_to_html import ( - generate_html_from_slide, - generate_react_component_from_html, +from api.v1.ppt.endpoints.prompts import ( + GENERATE_HTML_SYSTEM_PROMPT, + HTML_TO_REACT_SYSTEM_PROMPT, ) from services.documents_loader import DocumentsLoader @@ -157,6 +157,172 @@ def _guess_layout_type(layout_name: str) -> str: return "custom" +def _detect_llm_provider() -> Optional[dict]: + """Detect which LLM provider is available for vision calls.""" + openai_key = os.getenv("OPENAI_API_KEY") + if openai_key: + return {"provider": "openai", "api_key": openai_key} + + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + if anthropic_key: + model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250514") + return {"provider": "anthropic", "api_key": anthropic_key, "model": model} + + google_key = os.getenv("GOOGLE_API_KEY") + if google_key: + return {"provider": "google", "api_key": google_key} + + return None + + +async def _llm_generate_html( + provider: dict, img_b64: str, xml_content: str, fonts: Optional[List[str]] +) -> str: + """Generate HTML from slide screenshot + OXML using the available LLM provider.""" + fonts_text = ( + f"\nFONTS (Normalized root families used in this slide, use where required): {', '.join(fonts)}" + if fonts else "" + ) + user_text = f"OXML:\n{xml_content[:4000]}\n{fonts_text}" + + def _call_openai(): + from openai import OpenAI + client = OpenAI(api_key=provider["api_key"]) + data_url = f"data:image/png;base64,{img_b64}" + response = client.responses.create( + model="gpt-5", + input=[ + {"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT}, + {"role": "user", "content": [ + {"type": "input_image", "image_url": data_url}, + {"type": "input_text", "text": user_text}, + ]}, + ], + reasoning={"effort": "high"}, + text={"verbosity": "low"}, + ) + return getattr(response, "output_text", "") or getattr(response, "text", "") or "" + + def _call_anthropic(): + import anthropic + client = anthropic.Anthropic(api_key=provider["api_key"]) + response = client.messages.create( + model=provider.get("model", "claude-sonnet-4-5-20250514"), + max_tokens=8192, + system=GENERATE_HTML_SYSTEM_PROMPT, + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": img_b64, + }}, + {"type": "text", "text": user_text}, + ], + }], + ) + return response.content[0].text if response.content else "" + + def _call_google(): + import google.genai as genai + client = genai.Client(api_key=provider["api_key"]) + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=[ + GENERATE_HTML_SYSTEM_PROMPT, + {"inline_data": {"mime_type": "image/png", "data": img_b64}}, + user_text, + ], + ) + return response.text or "" + + if provider["provider"] == "openai": + return await asyncio.to_thread(_call_openai) + elif provider["provider"] == "anthropic": + return await asyncio.to_thread(_call_anthropic) + elif provider["provider"] == "google": + return await asyncio.to_thread(_call_google) + + raise ValueError(f"Unknown provider: {provider['provider']}") + + +async def _llm_generate_react( + provider: dict, html_content: str, img_b64: str +) -> str: + """Convert HTML to React TSX component using the available LLM provider.""" + user_text = f"HTML INPUT:\n{html_content}" + + def _call_openai(): + from openai import OpenAI + client = OpenAI(api_key=provider["api_key"]) + data_url = f"data:image/png;base64,{img_b64}" + response = client.responses.create( + model="gpt-5", + input=[ + {"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT}, + {"role": "user", "content": [ + {"type": "input_image", "image_url": data_url}, + {"type": "input_text", "text": user_text}, + ]}, + ], + reasoning={"effort": "minimal"}, + text={"verbosity": "low"}, + ) + return getattr(response, "output_text", "") or getattr(response, "text", "") or "" + + def _call_anthropic(): + import anthropic + client = anthropic.Anthropic(api_key=provider["api_key"]) + response = client.messages.create( + model=provider.get("model", "claude-sonnet-4-5-20250514"), + max_tokens=8192, + system=HTML_TO_REACT_SYSTEM_PROMPT, + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": img_b64, + }}, + {"type": "text", "text": user_text}, + ], + }], + ) + return response.content[0].text if response.content else "" + + def _call_google(): + import google.genai as genai + client = genai.Client(api_key=provider["api_key"]) + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=[ + HTML_TO_REACT_SYSTEM_PROMPT, + {"inline_data": {"mime_type": "image/png", "data": img_b64}}, + user_text, + ], + ) + return response.text or "" + + if provider["provider"] == "openai": + result = await asyncio.to_thread(_call_openai) + elif provider["provider"] == "anthropic": + result = await asyncio.to_thread(_call_anthropic) + elif provider["provider"] == "google": + result = await asyncio.to_thread(_call_google) + else: + raise ValueError(f"Unknown provider: {provider['provider']}") + + # Clean up: remove markdown fences and import/export lines + result = ( + result.replace("```tsx", "").replace("```typescript", "") + .replace("```javascript", "").replace("```", "") + ) + filtered_lines = [] + for line in result.split("\n"): + stripped = line.strip() + if not (stripped.startswith("import ") or stripped.startswith("export ")): + filtered_lines.append(line) + return "\n".join(filtered_lines) + + async def parse_master_deck(deck_id: uuid.UUID) -> None: """Parse a master deck PPTX asynchronously. Updates DB on completion/failure.""" async with async_session_maker() as session: @@ -181,6 +347,9 @@ async def parse_master_deck(deck_id: uuid.UUID) -> None: deck.parse_status = "completed" await session.commit() + # Bridge: register parsed layouts as a custom template + await _register_as_template(deck_id, deck.name, result["layouts"], session) + except Exception as e: traceback.print_exc() async with async_session_maker() as session: @@ -225,8 +394,9 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: pdf_path, temp_dir ) # Copy screenshots to permanent location + app_data = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "data")) deck_dir = os.path.join( - os.path.dirname(__file__), "..", "data", "clients", + app_data, "clients", str(client_id), "master_decks", str(deck_id), "screenshots" ) os.makedirs(deck_dir, exist_ok=True) @@ -252,15 +422,18 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: all_fonts.update(normalize_font_family_name(f) for f in raw if f) # 6. Process each slide layout through LLM pipeline - api_key = os.getenv("OPENAI_API_KEY") + llm_provider = _detect_llm_provider() layouts_result = [] + llm_layouts = min(len(layout_metas), len(screenshots)) + print(f"[MasterDeckParser] LLM provider: {llm_provider['provider'] if llm_provider else 'NONE'}") + print(f"[MasterDeckParser] Processing {len(layout_metas)} layouts, {llm_layouts} with screenshots for LLM") for idx, lm in enumerate(layout_metas): layout_entry = { "index": idx, "layout_name": lm["layout_name"], "layout_type": _guess_layout_type(lm["layout_name"]), - "xml_snippet": lm["xml_content"][:2000], # Store truncated XML for reference + "xml_snippet": lm["xml_content"][:2000], "fonts": list( {normalize_font_family_name(f) for f in extract_fonts_from_oxml(lm["xml_content"]) if f} ), @@ -269,35 +442,31 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: "screenshot_path": screenshots[idx] if idx < len(screenshots) else None, } - # Run LLM pipeline if API key available and we have a screenshot - if api_key and idx < len(screenshots) and os.path.exists(screenshots[idx]): + # Run LLM pipeline if provider available and we have a screenshot + if llm_provider and idx < len(screenshots) and os.path.exists(screenshots[idx]): try: + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating HTML...") with open(screenshots[idx], "rb") as img_f: img_b64 = base64.b64encode(img_f.read()).decode("utf-8") - # Step A: Generate HTML from slide screenshot + layout OXML - html = await generate_html_from_slide( - base64_image=img_b64, - media_type="image/png", - xml_content=lm["xml_content"], - api_key=api_key, - fonts=layout_entry["fonts"] or None, + html = await _llm_generate_html( + llm_provider, img_b64, lm["xml_content"], + layout_entry["fonts"] or None, ) html = html.replace("```html", "").replace("```", "") layout_entry["html"] = html - # Step B: Generate React component from HTML - react_code = await generate_react_component_from_html( - html_content=html, - api_key=api_key, - image_base64=img_b64, - media_type="image/png", + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating React...") + react_code = await _llm_generate_react( + llm_provider, html, img_b64, ) react_code = react_code.replace("```tsx", "").replace("```", "") layout_entry["react_code"] = react_code + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — done ({len(react_code)} chars)") except Exception as e: - print(f"LLM pipeline failed for layout {idx} ({lm['layout_name']}): {e}") + print(f"[MasterDeckParser] LLM FAILED for layout {idx} ({lm['layout_name']}): {e}") + traceback.print_exc() layout_entry["html"] = None layout_entry["react_code"] = None @@ -315,3 +484,63 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: "layouts": layouts_result, "thumbnail_path": thumbnail_path, } + + +async def _register_as_template( + deck_id: uuid.UUID, + deck_name: str, + layouts: list, + session, +) -> None: + """Bridge master deck layouts into the custom template system. + + Creates a TemplateModel and PresentationLayoutCodeModel records + so the parsed layouts appear in the template picker during generation. + """ + from models.sql.template import TemplateModel + from models.sql.presentation_layout_code import PresentationLayoutCodeModel + from sqlalchemy import select, delete + + try: + # Upsert TemplateModel — use deck_id as template id + existing = await session.get(TemplateModel, deck_id) + if existing: + existing.name = deck_name or "Custom Template" + else: + template = TemplateModel( + id=deck_id, + name=deck_name or "Custom Template", + description=f"Auto-generated from master deck: {deck_name}", + ) + session.add(template) + + # Remove old layout codes for this deck (reparse case) + await session.execute( + delete(PresentationLayoutCodeModel).where( + PresentationLayoutCodeModel.presentation == deck_id + ) + ) + + # Create PresentationLayoutCodeModel for each layout with react_code + for idx, layout in enumerate(layouts): + react_code = layout.get("react_code") + if not react_code: + continue + + layout_code = PresentationLayoutCodeModel( + presentation=deck_id, + layout_id=f"layout-{idx}", + layout_name=layout.get("layout_name", f"Layout {idx + 1}"), + layout_code=react_code, + fonts=layout.get("fonts"), + ) + session.add(layout_code) + + await session.commit() + print(f"Registered master deck {deck_id} as custom template with " + f"{sum(1 for l in layouts if l.get('react_code'))} layouts") + + except Exception as e: + print(f"Failed to register master deck as template: {e}") + # Don't fail the entire parse — template registration is non-critical + await session.rollback() diff --git a/backend/utils/llm_context.py b/backend/utils/llm_context.py new file mode 100644 index 0000000..daef597 --- /dev/null +++ b/backend/utils/llm_context.py @@ -0,0 +1,29 @@ +"""Context variable for propagating user/client/presentation info into LLM calls.""" +import contextvars +import uuid +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class LLMCallContext: + user_id: Optional[uuid.UUID] = field(default=None) + client_id: Optional[uuid.UUID] = field(default=None) + presentation_id: Optional[uuid.UUID] = field(default=None) + + +_llm_context: contextvars.ContextVar[Optional[LLMCallContext]] = contextvars.ContextVar( + "llm_call_context", default=None +) + + +def set_llm_context(ctx: LLMCallContext) -> None: + _llm_context.set(ctx) + + +def get_llm_context() -> Optional[LLMCallContext]: + return _llm_context.get() + + +def clear_llm_context() -> None: + _llm_context.set(None) diff --git a/backend/workers/main.py b/backend/workers/main.py index 64b368e..48154f8 100644 --- a/backend/workers/main.py +++ b/backend/workers/main.py @@ -7,6 +7,12 @@ import os from arq.connections import RedisSettings from arq.cron import cron +# Import all SQL models so SQLAlchemy can resolve FK references +from models.sql.user import UserModel # noqa: F401 +from models.sql.client import ClientModel # noqa: F401 +from models.sql.team import TeamModel # noqa: F401 +from models.sql.presentation import PresentationModel # noqa: F401 + from workers.master_deck_worker import parse_master_deck_task from workers.presentation_worker import generate_presentation_task from workers.retention_worker import retention_cleanup_task, retention_purge_task @@ -25,6 +31,6 @@ class WorkerSettings: cron(retention_purge_task, weekday=0, hour=3, minute=0), # Weekly Monday 3:00 AM ] max_jobs = 5 - job_timeout = 600 # 10 minutes + job_timeout = 1800 # 30 minutes (master deck parsing with LLM is slow) max_tries = 3 health_check_interval = 30 diff --git a/backend/workers/master_deck_worker.py b/backend/workers/master_deck_worker.py index e2fd376..d32df09 100644 --- a/backend/workers/master_deck_worker.py +++ b/backend/workers/master_deck_worker.py @@ -9,7 +9,7 @@ from services.master_deck_parser_service import parse_master_deck from services.redis_service import publish_job_progress -async def parse_master_deck_task(ctx: dict, job_id: str) -> None: +async def parse_master_deck_task(ctx: dict, job_id: str, deck_id: str = "") -> None: """ARQ task: parse a master deck via the existing parser service.""" job_uuid = uuid.UUID(job_id) @@ -30,9 +30,8 @@ async def parse_master_deck_task(ctx: dict, job_id: str) -> None: except Exception: pass - # The existing parser updates MasterDeckModel directly - # presentation_id is reused to store the deck_id for this job type - await parse_master_deck(job.presentation_id) + # Parse the master deck by its own ID + await parse_master_deck(uuid.UUID(deck_id)) job.status = "completed" job.progress = 100 diff --git a/docker-compose.yml b/docker-compose.yml index 81f4f85..8468d84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,7 @@ services: REDIS_URL: redis://redis:6379/0 APP_DATA_DIRECTORY: /app_data TEMP_DIRECTORY: /tmp/deckforge + PYTHONUNBUFFERED: "1" volumes: - app_data:/app_data depends_on: @@ -74,6 +75,7 @@ services: - "3000:3000" environment: CAN_CHANGE_KEYS: "false" + API_INTERNAL_URL: "http://api:8000" depends_on: - api diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2584759..9221563 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . +ENV API_INTERNAL_URL=http://api:8000 RUN npm run build FROM node:20-alpine diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx index 58b87e7..c235793 100644 --- a/frontend/app/admin/analytics/page.tsx +++ b/frontend/app/admin/analytics/page.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/store/store'; import { BarChart3, Users, @@ -10,15 +12,10 @@ import { AlertTriangle, CheckCircle2, Loader2, + Cpu, + Zap, } from 'lucide-react'; import { Card } from '@/components/ui/card'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { getHeader } from '@/app/(presentation-generator)/services/api/header'; import { cn } from '@/lib/utils'; @@ -47,6 +44,16 @@ interface PerformanceData { total_jobs: number; } +interface AIUsageData { + total_calls: number; + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; + by_provider: { provider: string; calls: number; tokens: number }[]; + by_model: { model: string; calls: number; tokens: number }[]; + daily: { date: string; calls: number; tokens: number }[]; +} + async function fetchAnalytics(endpoint: string, clientId?: string) { const params = clientId ? `?client_id=${clientId}` : ''; const response = await fetch(`/api/v1/admin/analytics/${endpoint}${params}`, { @@ -141,32 +148,44 @@ function StatusBar({ distribution }: { distribution: Record }) { ); } +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + export default function AnalyticsPage() { + const user = useSelector((state: RootState) => state.auth.user); const [overview, setOverview] = useState(null); const [usage, setUsage] = useState(null); const [quality, setQuality] = useState(null); const [performance, setPerformance] = useState(null); + const [aiUsage, setAiUsage] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [clientId, setClientId] = useState(''); + + // Auto-filter by user's clientId + const clientId = user?.clientId || undefined; useEffect(() => { + if (!user) return; loadAll(); - }, [clientId]); + }, [user?.clientId]); const loadAll = async () => { setIsLoading(true); - const cid = clientId || undefined; try { - const [o, u, q, p] = await Promise.all([ - fetchAnalytics('overview', cid), - fetchAnalytics('usage', cid), - fetchAnalytics('quality', cid), - fetchAnalytics('performance', cid), + const [o, u, q, p, ai] = await Promise.all([ + fetchAnalytics('overview', clientId), + fetchAnalytics('usage', clientId), + fetchAnalytics('quality', clientId), + fetchAnalytics('performance', clientId), + fetchAnalytics('ai-usage', clientId).catch(() => null), ]); setOverview(o); setUsage(u); setQuality(q); setPerformance(p); + setAiUsage(ai); } catch (e) { console.error('Analytics load error:', e); } finally { @@ -225,13 +244,11 @@ export default function AnalyticsPage() { {/* Charts Row */}
- {/* Usage Chart */}

Presentations per Day

{usage ? :

Loading...

}
- {/* Status Distribution */}

Status Distribution

{quality ? ( @@ -269,6 +286,60 @@ export default function AnalyticsPage() {
)} + {/* AI Usage Section */} + {aiUsage && ( + <> +

AI Model Usage

+
+ + + +
+ + {aiUsage.by_provider.length > 0 && ( + +

Usage by Provider

+
+ {aiUsage.by_provider.map((p) => { + const maxCalls = Math.max(...aiUsage.by_provider.map((x) => x.calls), 1); + return ( +
+ {p.provider} +
+
+ {p.calls} calls +
+
+ + {formatTokens(p.tokens)} tokens + +
+ ); + })} +
+
+ )} + + )} + {/* Top Users */} {usage && usage.top_users.length > 0 && ( diff --git a/frontend/app/admin/clients/[id]/master-decks/page.tsx b/frontend/app/admin/clients/[id]/master-decks/page.tsx index d0263d0..03c5179 100644 --- a/frontend/app/admin/clients/[id]/master-decks/page.tsx +++ b/frontend/app/admin/clients/[id]/master-decks/page.tsx @@ -335,29 +335,38 @@ function DeckCard({ {layouts.length > 0 && (
- {layouts.map((layout, idx) => ( - - ))} + {layouts.map((layout, idx) => { + const screenshotPath = layout.screenshot_path as string | undefined; + const screenshotFilename = screenshotPath ? screenshotPath.split('/').pop() : null; + return ( + + ); + })}
)} diff --git a/frontend/app/admin/components/AdminSidebar.tsx b/frontend/app/admin/components/AdminSidebar.tsx index 46c1450..34360b3 100644 --- a/frontend/app/admin/components/AdminSidebar.tsx +++ b/frontend/app/admin/components/AdminSidebar.tsx @@ -8,9 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { Users, Building2, - Users2, - Layers, - Palette, + HardDrive, FileText, BarChart3, Settings, @@ -27,7 +25,7 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { label: 'Users', href: '/admin/users', icon: , roles: ['super_admin'] }, { label: 'Clients', href: '/admin/clients', icon: , roles: ['super_admin', 'client_admin'] }, - { label: 'Teams', href: '/admin/clients', icon: , roles: ['super_admin', 'client_admin'] }, + { label: 'Storage', href: '/admin/storage', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Audit Log', href: '/admin/audit', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Analytics', href: '/admin/analytics', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Settings', href: '/admin/settings', icon: , roles: ['super_admin'] }, diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx index cdd1cb8..f49b848 100644 --- a/frontend/app/admin/settings/page.tsx +++ b/frontend/app/admin/settings/page.tsx @@ -1,17 +1,283 @@ 'use client'; -import { Settings } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { + Settings, + Loader2, + Check, + Cpu, + Image as ImageIcon, + Key, +} from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getHeader } from '@/app/(presentation-generator)/services/api/header'; +import { toast } from 'sonner'; + +interface SystemSettings { + llm_provider: string; + llm_model: string; + image_provider: string; + anthropic_api_key_set: boolean; + openai_api_key_set: boolean; + google_api_key_set: boolean; + available_llm_providers: string[]; + available_image_providers: string[]; +} + +const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + google: 'Google (Gemini)', + ollama: 'Ollama (Local)', + custom: 'Custom', + 'dall-e-3': 'DALL-E 3', + 'gpt-image-1.5': 'GPT Image 1.5', + pexels: 'Pexels (Stock)', + pixabay: 'Pixabay (Stock)', + comfyui: 'ComfyUI', +}; export default function SettingsPage() { - return ( -
-

System Settings

-
-
- -

System settings (LLM config, image providers) will be available here.

+ const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Form state + const [llmProvider, setLlmProvider] = useState(''); + const [llmModel, setLlmModel] = useState(''); + const [imageProvider, setImageProvider] = useState(''); + const [anthropicKey, setAnthropicKey] = useState(''); + const [openaiKey, setOpenaiKey] = useState(''); + const [googleKey, setGoogleKey] = useState(''); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/v1/admin/settings', { headers: getHeader() }); + if (res.status === 403) { + setError('Super admin access required'); + return; + } + if (!res.ok) throw new Error('Failed to load settings'); + const data: SystemSettings = await res.json(); + setSettings(data); + setLlmProvider(data.llm_provider); + setLlmModel(data.llm_model); + setImageProvider(data.image_provider); + } catch (e) { + setError('Failed to load settings'); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const body: Record = {}; + if (llmProvider !== settings?.llm_provider) body.llm_provider = llmProvider; + if (llmModel !== settings?.llm_model) body.llm_model = llmModel; + if (imageProvider !== settings?.image_provider) body.image_provider = imageProvider; + if (anthropicKey) body.anthropic_api_key = anthropicKey; + if (openaiKey) body.openai_api_key = openaiKey; + if (googleKey) body.google_api_key = googleKey; + + if (Object.keys(body).length === 0) { + toast.info('No changes to save'); + setSaving(false); + return; + } + + const res = await fetch('/api/v1/admin/settings', { + method: 'PUT', + headers: { ...getHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Save failed'); + } + toast.success('Settings saved'); + setAnthropicKey(''); + setOpenaiKey(''); + setGoogleKey(''); + loadSettings(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

System Settings

+
+
+ +

{error}

+
+ ); + } + + return ( +
+
+

System Settings

+ +
+ +

+ Runtime configuration. Changes apply immediately but don't persist across container restarts. + For permanent changes, update environment variables in .env / docker-compose.yml. +

+ + {/* LLM Configuration */} + +
+ +

LLM Provider

+
+ +
+
+ + +
+ +
+ + setLlmModel(e.target.value)} + placeholder="e.g. claude-sonnet-4-6" + /> +
+
+
+ + {/* Image Generation */} + +
+ +

Image Generation

+
+ +
+ + +
+
+ + {/* API Keys */} + +
+ +

API Keys

+
+

+ Leave blank to keep existing keys. Enter a new value to update. +

+ +
+
+ + setAnthropicKey(e.target.value)} + placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'} + /> +
+ +
+ + setOpenaiKey(e.target.value)} + placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'} + /> +
+ +
+ + setGoogleKey(e.target.value)} + placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'} + /> +
+
+
); } diff --git a/frontend/app/admin/storage/page.tsx b/frontend/app/admin/storage/page.tsx new file mode 100644 index 0000000..1858fe8 --- /dev/null +++ b/frontend/app/admin/storage/page.tsx @@ -0,0 +1,252 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/store/store'; +import { + HardDrive, + FileText, + Download, + Trash2, + Loader2, + FolderOpen, +} from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { getHeader } from '@/app/(presentation-generator)/services/api/header'; +import { toast } from 'sonner'; + +interface StorageSummary { + total_presentations: number; + total_files: number; + total_size_bytes: number; +} + +interface StoragePresentation { + id: string; + title: string | null; + status: string; + created_at: string | null; + file_count: number; + total_size_bytes: number; + has_export: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +const STATUS_COLORS: Record = { + draft: 'bg-yellow-100 text-yellow-700', + in_review: 'bg-blue-100 text-blue-700', + approved: 'bg-green-100 text-green-700', +}; + +export default function StoragePage() { + const user = useSelector((state: RootState) => state.auth.user); + const clientId = user?.clientId || undefined; + + const [summary, setSummary] = useState(null); + const [presentations, setPresentations] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + + const load = useCallback(async () => { + if (!clientId) return; + setLoading(true); + try { + const params = `?client_id=${clientId}`; + const headers = getHeader(); + const [summaryRes, presRes] = await Promise.all([ + fetch(`/api/v1/admin/storage/summary${params}`, { headers }), + fetch(`/api/v1/admin/storage/presentations${params}`, { headers }), + ]); + if (summaryRes.ok) setSummary(await summaryRes.json()); + if (presRes.ok) setPresentations(await presRes.json()); + } catch (e) { + console.error('Storage load error:', e); + } finally { + setLoading(false); + } + }, [clientId]); + + useEffect(() => { + load(); + }, [load]); + + const handleDownload = (id: string) => { + window.open(`/api/v1/admin/storage/presentations/${id}/download`, '_blank'); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + const res = await fetch(`/api/v1/admin/storage/presentations/${deleteTarget.id}`, { + method: 'DELETE', + headers: getHeader(), + }); + if (res.ok) { + toast.success('Presentation deleted'); + setDeleteTarget(null); + load(); + } else { + toast.error('Failed to delete'); + } + } catch { + toast.error('Failed to delete'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+

Storage

+ + {/* Summary Cards */} + {summary && ( +
+ +
+
+

Presentations

+

{summary.total_presentations}

+
+
+ +
+
+
+ +
+
+

Export Files

+

{summary.total_files}

+
+
+ +
+
+
+ +
+
+

Total Size

+

{formatBytes(summary.total_size_bytes)}

+
+
+ +
+
+
+
+ )} + + {/* Presentations Table */} + +
+ + + + + + + + + + + + + {presentations.length === 0 ? ( + + + + ) : ( + presentations.map((p) => ( + + + + + + + + + )) + )} + +
TitleStatusCreatedFilesSizeActions
+ No presentations found. +
+ {p.title || 'Untitled'} + + + {p.status} + + + {p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'} + {p.file_count} + {p.total_size_bytes > 0 ? formatBytes(p.total_size_bytes) : '—'} + +
+ {p.has_export && ( + + )} + +
+
+
+
+ + {/* Delete Confirmation */} + !open && setDeleteTarget(null)}> + + + Delete Presentation + +

+ Are you sure you want to delete "{deleteTarget?.title || 'Untitled'}"? + Associated files will be cleaned up by the retention service. +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index b0c0d0b..60e8b4c 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,15 +1,21 @@ +const API_URL = process.env.API_INTERNAL_URL || 'http://localhost:8000'; + const nextConfig = { reactStrictMode: false, distDir: ".next-build", - - // Rewrites for development - proxy font requests to FastAPI backend + + // Proxy API and static asset requests to FastAPI backend async rewrites() { return [ { - source: '/app_data/fonts/:path*', - destination: 'http://localhost:8000/app_data/fonts/:path*', + source: '/api/v1/:path*', + destination: `${API_URL}/api/v1/:path*`, + }, + { + source: '/app_data/:path*', + destination: `${API_URL}/app_data/:path*`, }, ]; }, diff --git a/frontend/store/slices/authSlice.ts b/frontend/store/slices/authSlice.ts index 07bb386..28f2bb8 100644 --- a/frontend/store/slices/authSlice.ts +++ b/frontend/store/slices/authSlice.ts @@ -5,6 +5,7 @@ export interface User { email: string; displayName: string; role: "super_admin" | "client_admin" | "user"; + clientId?: string | null; } interface AuthState {