"""User frontend routes (LinkedIn OAuth protected).""" import asyncio import json from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Optional from uuid import UUID from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException, UploadFile from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse from pydantic import BaseModel from loguru import logger from src.config import settings from src.database import db from src.orchestrator import orchestrator from src.web.user.auth import ( get_user_session, get_user_session_async, set_user_session, clear_user_session, get_supabase_login_url, handle_oauth_callback, UserSession, handle_email_password_login, create_email_password_user, sign_out, USER_SESSION_COOKIE ) from src.web.user.password_auth import ( hash_password, verify_password, generate_invitation_token, get_invitation_expiry, is_token_expired, validate_password_strength ) from src.services.email_service import ( send_approval_request_email, send_company_approval_request_email, send_company_review_notification_email, send_decision_notification_email, generate_token, validate_token, mark_token_used, ) from src.services.background_jobs import ( JobType, JobStatus, run_post_scraping, run_profile_analysis, run_post_categorization, run_post_type_analysis, run_full_analysis_pipeline, run_post_recategorization ) from src.services.db_job_manager import job_manager from src.services.storage_service import storage # Router for user frontend user_router = APIRouter(tags=["user"]) # Templates templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" / "user") base_templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") # Store for progress updates progress_store = {} async def get_user_profile_picture(user_id: UUID) -> Optional[str]: """Get profile picture URL with priority: LinkedInAccount > Profile.profile_picture. Priority: 1. LinkedInAccount.linkedin_picture (if account exists and is active) 2. Profile.profile_picture (from setup process) Note: session.linkedin_picture (OAuth login) should be checked by caller first. """ # Check for connected LinkedIn account first linkedin_account = await db.get_linkedin_account(user_id) if linkedin_account and linkedin_account.is_active and linkedin_account.linkedin_picture: return linkedin_account.linkedin_picture # Fall back to profile picture from setup process profile = await db.get_profile(user_id) if profile and profile.profile_picture: return profile.profile_picture return None async def get_user_avatar(session: UserSession, user_id: UUID) -> Optional[str]: """Get user avatar URL with complete priority order. Priority: 1. LinkedInAccount.linkedin_picture (connected LinkedIn account for auto-posting) 2. Profile.profile_picture (from setup process) 3. session.linkedin_picture (from OAuth login) Returns None if no avatar is available (caller should show initials). """ # First check LinkedInAccount and Profile avatar = await get_user_profile_picture(user_id) if avatar: return avatar # Fall back to session picture (OAuth login) if session and session.linkedin_picture: return session.linkedin_picture return None async def get_employee_permissions_or_default(user_id: UUID, company_id: UUID) -> dict: """Get employee permissions for a company, returning all-true defaults if no row exists.""" perms = await db.get_employee_permissions(user_id, company_id) if perms: return perms.model_dump() return { "can_create_posts": True, "can_view_posts": True, "can_edit_posts": True, "can_schedule_posts": True, "can_manage_post_types": True, "can_do_research": True, "can_see_in_calendar": True, } def require_user_session(request: Request) -> Optional[UserSession]: """Check if user is authenticated, redirect to login if not.""" session = get_user_session(request) if not session: return None return session # ==================== PUBLIC ROUTES ==================== @user_router.get("/privacy-policy", response_class=HTMLResponse) async def privacy_policy(request: Request): """Public privacy policy page.""" from datetime import date return base_templates.TemplateResponse("privacy_policy.html", { "request": request, "current_date": date.today().strftime("%d.%m.%Y") }) # ==================== AUTH ROUTES ==================== @user_router.get("/login", response_class=HTMLResponse) async def login_page(request: Request, error: str = None): """User login page with LinkedIn OAuth button.""" # If already logged in, redirect to dashboard session = get_user_session(request) if session: return RedirectResponse(url="/", status_code=302) return templates.TemplateResponse("login.html", { "request": request, "error": error }) @user_router.get("/auth/linkedin") async def start_oauth(request: Request): """Start LinkedIn OAuth flow via Supabase.""" # Build callback URL callback_url = settings.supabase_redirect_url if not callback_url: # Fallback to constructing from request callback_url = str(request.url_for("oauth_callback")) login_url = get_supabase_login_url(callback_url) return RedirectResponse(url=login_url, status_code=302) @user_router.get("/auth/callback") async def oauth_callback( request: Request, access_token: str = None, refresh_token: str = None, error: str = None, error_description: str = None ): """Handle OAuth callback from Supabase.""" if error: logger.error(f"OAuth error: {error} - {error_description}") return RedirectResponse(url=f"/login?error={error}", status_code=302) # Supabase returns tokens in URL hash, not query params # We need to handle this client-side and redirect back # Check if we have the tokens if not access_token: # Render a page that extracts hash params and redirects return templates.TemplateResponse("auth_callback.html", { "request": request }) # We have the tokens, try to authenticate session, access_tok, refresh_tok = await handle_oauth_callback(access_token, refresh_token) if not session: return RedirectResponse(url="/not-authorized", status_code=302) # Check onboarding status if not session.is_onboarding_complete: if session.account_type == "company": redirect_url = "/onboarding/company" else: redirect_url = "/onboarding/profile" else: redirect_url = "/" # Success - set session and redirect response = RedirectResponse(url=redirect_url, status_code=302) set_user_session(response, session, access_tok, refresh_tok) return response @user_router.get("/logout") async def logout(request: Request): """Log out user from Supabase Auth.""" # Try to sign out from Supabase access_token = request.cookies.get(USER_SESSION_COOKIE) if access_token: await sign_out(access_token) response = RedirectResponse(url="/login", status_code=302) clear_user_session(response) return response @user_router.get("/not-authorized", response_class=HTMLResponse) async def not_authorized_page(request: Request): """Page shown when user's LinkedIn profile doesn't match any customer.""" return templates.TemplateResponse("not_authorized.html", { "request": request }) # ==================== REGISTRATION ROUTES ==================== @user_router.get("/register", response_class=HTMLResponse) async def register_page(request: Request): """Registration page - redirect directly to company registration. GHOSTWRITER FEATURE DISABLED: To re-enable, show register.html template instead. """ session = get_user_session(request) if session and session.is_onboarding_complete: return RedirectResponse(url="/", status_code=302) # Redirect directly to company registration (ghostwriter disabled) return RedirectResponse(url="/register/company", status_code=302) # ==================== GHOSTWRITER REGISTRATION (DISABLED) ==================== # To re-enable ghostwriter registration: # 1. Uncomment these routes # 2. Change /register route to show register.html template # 3. Update login.html to show ghostwriter registration link # @user_router.get("/register/ghostwriter", response_class=HTMLResponse) # async def register_ghostwriter_page(request: Request, error: str = None): # """Ghostwriter registration page.""" # session = get_user_session(request) # if session and session.is_onboarding_complete: # return RedirectResponse(url="/", status_code=302) # return templates.TemplateResponse("register_ghostwriter.html", { # "request": request, # "error": error # }) # @user_router.post("/register/ghostwriter") # async def register_ghostwriter( # request: Request, # email: str = Form(...), # password: str = Form(...), # password_confirm: str = Form(...) # ): # """Handle ghostwriter registration with email/password.""" # if password != password_confirm: # return templates.TemplateResponse("register_ghostwriter.html", { # "request": request, # "error": "Passwörter stimmen nicht überein" # }) # # # Create user via Supabase Auth # session, access_token, refresh_token, error = await create_email_password_user( # email, password, "ghostwriter" # ) # # if not session: # return templates.TemplateResponse("register_ghostwriter.html", { # "request": request, # "error": error or "E-Mail bereits registriert oder ungültig" # }) # # # Set session and redirect to onboarding # response = RedirectResponse(url="/onboarding/profile", status_code=302) # set_user_session(response, session, access_token, refresh_token) # return response @user_router.get("/register/company", response_class=HTMLResponse) async def register_company_page(request: Request, error: str = None): """Company registration page.""" session = get_user_session(request) if session and session.is_onboarding_complete: return RedirectResponse(url="/", status_code=302) return templates.TemplateResponse("register_company.html", { "request": request, "error": error }) @user_router.post("/register/company") async def register_company( request: Request, license_key: str = Form(...), company_name: str = Form(...), email: str = Form(...), password: str = Form(...), password_confirm: str = Form(...) ): """Handle company registration with email/password and license key validation.""" try: # 1. Password match validation if password != password_confirm: return templates.TemplateResponse("register_company.html", { "request": request, "error": "Passwörter stimmen nicht überein", "company_name": company_name, "email": email }) # 2. LICENSE KEY VALIDATION (BEFORE account creation) license_key = license_key.strip().upper() license = await db.get_license_key(license_key) if not license: return templates.TemplateResponse("register_company.html", { "request": request, "error": "Ungültiger Lizenzschlüssel", "company_name": company_name, "email": email }) if license.used: return templates.TemplateResponse("register_company.html", { "request": request, "error": "Dieser Lizenzschlüssel wurde bereits verwendet", "company_name": company_name, "email": email }) # 3. Create user with company account type via Supabase Auth session, access_token, refresh_token, error = await create_email_password_user( email, password, "company" ) if not session: return templates.TemplateResponse("register_company.html", { "request": request, "error": error or "E-Mail bereits registriert oder ungültig", "company_name": company_name, "email": email }) # 4. Create the company record with license key reference from src.database.models import Company company = Company( name=company_name, owner_user_id=UUID(session.user_id), license_key_id=license.id ) created_company = await db.create_company(company) # 5. Mark license key as used await db.mark_license_key_used(license_key, created_company.id) # 6. Update profile with company_id await db.update_profile(UUID(session.user_id), {"company_id": str(created_company.id)}) # 7. Update session session.company_id = str(created_company.id) session.company_name = company_name # 8. Set session and redirect to company onboarding response = RedirectResponse(url="/onboarding/company", status_code=302) set_user_session(response, session, access_token, refresh_token) return response except Exception as e: logger.error(f"Error in company registration: {e}") return templates.TemplateResponse("register_company.html", { "request": request, "error": "Ein Fehler ist aufgetreten. Bitte versuche es erneut.", "company_name": company_name, "email": email }) @user_router.post("/auth/login") async def email_password_login( request: Request, email: str = Form(...), password: str = Form(...) ): """Handle email/password login via Supabase Auth.""" session, access_token, refresh_token = await handle_email_password_login(email, password) if not session: return templates.TemplateResponse("login.html", { "request": request, "error": "Ungültige E-Mail oder Passwort" }) # Check onboarding status if not session.is_onboarding_complete: # Redirect to appropriate onboarding step if session.account_type == "company": redirect_url = "/onboarding/company" else: redirect_url = "/onboarding/profile" response = RedirectResponse(url=redirect_url, status_code=302) else: response = RedirectResponse(url="/", status_code=302) set_user_session(response, session, access_token, refresh_token) return response # ==================== INVITATION ROUTES ==================== @user_router.get("/invite/{token}", response_class=HTMLResponse) async def invite_page(request: Request, token: str): """Display invitation acceptance page.""" invitation = await db.get_invitation_by_token(token) if not invitation: return templates.TemplateResponse("invite_accept.html", { "request": request, "error": "Einladung nicht gefunden", "expired": True }) # Check if expired if is_token_expired(invitation.expires_at): return templates.TemplateResponse("invite_accept.html", { "request": request, "expired": True }) # Check if already accepted inv_status = invitation.status.value if hasattr(invitation.status, 'value') else invitation.status if inv_status != "pending": return templates.TemplateResponse("invite_accept.html", { "request": request, "error": "Diese Einladung wurde bereits verwendet", "expired": True }) # Get company info company = await db.get_company(invitation.company_id) inviter = await db.get_user(invitation.invited_by_user_id) return templates.TemplateResponse("invite_accept.html", { "request": request, "invitation": invitation, "company_name": company.name if company else "Unbekannt", "inviter_name": inviter.linkedin_name or inviter.email if inviter else "Unbekannt" }) @user_router.post("/invite/{token}/accept") async def accept_invitation( request: Request, token: str, email: str = Form(...), password: str = Form(...), password_confirm: str = Form(...) ): """Accept an invitation and create employee account.""" invitation = await db.get_invitation_by_token(token) if not invitation: return templates.TemplateResponse("invite_accept.html", { "request": request, "error": "Einladung nicht gefunden", "expired": True }) # Validate password if password != password_confirm: company = await db.get_company(invitation.company_id) inviter = await db.get_user(invitation.invited_by_user_id) return templates.TemplateResponse("invite_accept.html", { "request": request, "invitation": invitation, "company_name": company.name if company else "Unbekannt", "inviter_name": inviter.linkedin_name or inviter.email if inviter else "Unbekannt", "error": "Passwörter stimmen nicht überein" }) is_valid, error_msg = validate_password_strength(password) if not is_valid: company = await db.get_company(invitation.company_id) inviter = await db.get_user(invitation.invited_by_user_id) return templates.TemplateResponse("invite_accept.html", { "request": request, "invitation": invitation, "company_name": company.name if company else "Unbekannt", "inviter_name": inviter.linkedin_name or inviter.email if inviter else "Unbekannt", "error": error_msg }) # Create employee user via Supabase Auth session, access_token, refresh_token, error = await create_email_password_user( email=invitation.email, password=password, account_type="employee", company_id=str(invitation.company_id) ) if not session: return templates.TemplateResponse("invite_accept.html", { "request": request, "error": error or "Konto konnte nicht erstellt werden. E-Mail bereits vergeben?", "expired": True }) # Mark invitation as accepted from datetime import datetime await db.update_invitation(invitation.id, { "status": "accepted", "accepted_at": datetime.utcnow(), "accepted_by_user_id": session.user_id }) # Get company name for session company = await db.get_company(invitation.company_id) session.company_name = company.name if company else None # Redirect to onboarding response = RedirectResponse(url="/onboarding/profile", status_code=302) set_user_session(response, session, access_token, refresh_token) return response # ==================== ONBOARDING ROUTES ==================== def get_ghostwriter_steps(current: str) -> list: """Get onboarding steps for ghostwriter with current step highlighted.""" steps = [ {"name": "Profil", "status": "pending"}, {"name": "Posts", "status": "pending"}, {"name": "Typen", "status": "pending"}, {"name": "Fertig", "status": "pending"} ] step_order = ["profile", "posts", "post_types", "complete"] current_idx = step_order.index(current) if current in step_order else 0 for i, step in enumerate(steps): if i < current_idx: step["status"] = "done" elif i == current_idx: step["status"] = "active" return steps def get_company_steps(current: str) -> list: """Get onboarding steps for company with current step highlighted.""" steps = [ {"name": "Unternehmen", "status": "pending"}, {"name": "Strategie", "status": "pending"}, {"name": "Fertig", "status": "pending"} ] step_order = ["company", "strategy", "complete"] current_idx = step_order.index(current) if current in step_order else 0 for i, step in enumerate(steps): if i < current_idx: step["status"] = "done" elif i == current_idx: step["status"] = "active" return steps def get_employee_steps(current: str) -> list: """Get onboarding steps for employee with current step highlighted.""" steps = [ {"name": "Profil", "status": "pending"}, {"name": "Posts", "status": "pending"}, {"name": "Typen", "status": "pending"}, {"name": "Fertig", "status": "pending"} ] step_order = ["profile", "posts", "post_types", "complete"] current_idx = step_order.index(current) if current in step_order else 0 for i, step in enumerate(steps): if i < current_idx: step["status"] = "done" elif i == current_idx: step["status"] = "active" return steps @user_router.get("/onboarding/profile", response_class=HTMLResponse) async def onboarding_profile_page(request: Request): """Profile setup page for ghostwriter/employee onboarding.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Check if already completed AND has user_id (to avoid redirect loop) if session.is_onboarding_complete and session.user_id: return RedirectResponse(url="/", status_code=302) # Prefill data from session prefill = { "name": session.linkedin_name or "", "linkedin_url": f"https://linkedin.com/in/{session.linkedin_vanity_name}" if session.linkedin_vanity_name else "" } # Use employee template for employees (simpler form) if session.account_type == "employee": return templates.TemplateResponse("onboarding/profile_employee.html", { "request": request, "session": session, "steps": get_employee_steps("profile"), "prefill": prefill }) # Ghostwriter template (separate customer/ghostwriter fields) return templates.TemplateResponse("onboarding/profile.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("profile"), "prefill": prefill }) @user_router.post("/onboarding/profile") async def onboarding_profile_submit( request: Request, background_tasks: BackgroundTasks, linkedin_url: str = Form(...), # Employee form fields (simpler) name: str = Form(None), email: str = Form(None), is_employee: str = Form(None), # Ghostwriter form fields customer_name: str = Form(None), ghostwriter_name: str = Form(None), creator_email: str = Form(""), customer_email: str = Form(""), writing_style_notes: str = Form(""), company_name: str = Form("") ): """Handle profile setup form submission.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Determine if this is an employee submission is_employee_flow = is_employee == "true" or session.account_type == "employee" # For employees: name = customer_name = ghostwriter_name, email = both emails if is_employee_flow: actual_name = name or session.linkedin_name or "Mitarbeiter" actual_customer_name = actual_name actual_ghostwriter_name = actual_name actual_email = email or session.email or "" actual_creator_email = actual_email actual_customer_email = actual_email else: actual_customer_name = customer_name or "" actual_ghostwriter_name = ghostwriter_name or "" actual_creator_email = creator_email actual_customer_email = customer_email try: # Update profile with LinkedIn info and onboarding data user_id = UUID(session.user_id) profile_updates = { "linkedin_url": linkedin_url, "display_name": actual_ghostwriter_name, "writing_style_notes": writing_style_notes or None, "creator_email": actual_creator_email or None, "customer_email": actual_customer_email or None, "onboarding_status": "profile_setup" } await db.update_profile(user_id, profile_updates) logger.info(f"Updated profile {user_id} with LinkedIn URL: {linkedin_url}") # Update session session.onboarding_status = "profile_setup" session.linkedin_name = actual_ghostwriter_name # Determine whether to scrape or skip should_scrape = True existing_posts = await db.get_linkedin_posts(user_id) if existing_posts: # This user already has posts (e.g. re-onboarding) should_scrape = False logger.info(f"Skipping scraping - {len(existing_posts)} posts already exist for user {user_id}") if should_scrape: job = await job_manager.create_job(JobType.POST_SCRAPING, str(user_id)) background_tasks.add_task(run_post_scraping, user_id, linkedin_url, job.id) logger.info(f"Started background scraping for user {user_id}") response = RedirectResponse(url="/onboarding/posts", status_code=302) set_user_session(response, session) return response except Exception as e: logger.error(f"Error in profile onboarding: {e}") # Use appropriate template based on account type template_name = "onboarding/profile_employee.html" if is_employee_flow else "onboarding/profile.html" steps = get_employee_steps("profile") if is_employee_flow else get_ghostwriter_steps("profile") return templates.TemplateResponse(template_name, { "request": request, "session": session, "steps": steps, "error": str(e), "prefill": { "linkedin_url": linkedin_url, "name": name or actual_customer_name, "email": email or actual_creator_email, "customer_name": actual_customer_name, "ghostwriter_name": actual_ghostwriter_name, "creator_email": actual_creator_email, "customer_email": actual_customer_email, "writing_style_notes": writing_style_notes, "company_name": company_name } }) @user_router.get("/onboarding/posts", response_class=HTMLResponse) async def onboarding_posts_page(request: Request): """Posts scraping/adding page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if not session.user_id: return RedirectResponse(url="/onboarding/profile", status_code=302) user_id = UUID(session.user_id) profile = await db.get_profile(user_id) scraped_posts = await db.get_linkedin_posts(user_id) # Manual posts are now stored as LinkedInPost with manual:// URL example_posts = [p for p in scraped_posts if p.post_url and p.post_url.startswith("manual://")] reference_profiles = await db.get_reference_profiles(user_id) total_posts = len(scraped_posts) # Check if scraping is in progress active_jobs = job_manager.get_active_jobs(session.user_id) scraping_in_progress = any(j.job_type == JobType.POST_SCRAPING for j in active_jobs) return templates.TemplateResponse("onboarding/posts.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("posts"), "profile": profile, "scraped_posts_count": len(scraped_posts), "example_posts": example_posts, "reference_profiles": reference_profiles, "total_posts": total_posts, "scraping_in_progress": scraping_in_progress }) @user_router.post("/onboarding/posts") async def onboarding_posts_submit(request: Request): """Move to next onboarding step after posts.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Update user status if session.user_id: await db.update_user(UUID(session.user_id), {"onboarding_status": "posts_scraped"}) session.onboarding_status = "posts_scraped" response = RedirectResponse(url="/onboarding/post-types", status_code=302) set_user_session(response, session) return response @user_router.get("/api/posts-count") async def api_posts_count(request: Request): """Get current post count for onboarding polling.""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) user_id = UUID(session.user_id) posts = await db.get_linkedin_posts(user_id) # Check if scraping is active active_jobs = job_manager.get_active_jobs(session.user_id) scraping_active = any(j.job_type == JobType.POST_SCRAPING for j in active_jobs) return JSONResponse({ "count": len(posts), "scraping_active": scraping_active }) @user_router.post("/api/onboarding/add-manual-post") async def api_add_manual_post(request: Request): """Add a manual example post during onboarding.""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: data = await request.json() post_text = data.get("post_text", "").strip() if not post_text: return JSONResponse({"error": "Post text required"}, status_code=400) from uuid import uuid4 from datetime import datetime from src.database.models import LinkedInPost manual_post = LinkedInPost( user_id=UUID(session.user_id), post_text=post_text, post_url=f"manual://{uuid4()}", post_date=datetime.now(timezone.utc), classification_method="manual" ) saved_posts = await db.save_linkedin_posts([manual_post]) saved = saved_posts[0] if saved_posts else manual_post return JSONResponse({"success": True, "id": str(saved.id)}) except Exception as e: logger.error(f"Error adding manual post: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.post("/api/onboarding/rescrape") async def api_rescrape(request: Request, background_tasks: BackgroundTasks): """Trigger re-scraping of LinkedIn posts.""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) if not profile or not profile.linkedin_url: return JSONResponse({"error": "No LinkedIn URL found"}, status_code=400) # Create job and start scraping job = await job_manager.create_job(JobType.POST_SCRAPING, session.user_id) background_tasks.add_task(run_post_scraping, user_id, profile.linkedin_url, job.id) return JSONResponse({"success": True, "job_id": job.id}) except Exception as e: logger.error(f"Error starting rescrape: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.get("/onboarding/post-types", response_class=HTMLResponse) async def onboarding_post_types_page(request: Request): """Post types selection page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) return templates.TemplateResponse("onboarding/post_types.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("post_types") }) @user_router.post("/onboarding/post-types") async def onboarding_post_types_submit( request: Request, post_types_json: str = Form("{}") ): """Save post types and move to categorization.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: import json post_types_data = json.loads(post_types_json) if post_types_data and session.user_id: from src.database.models import PostType user_id = UUID(session.user_id) post_types = [] for pt_data in post_types_data: post_type = PostType( user_id=user_id, name=pt_data.get("name", ""), description=pt_data.get("description"), identifying_keywords=pt_data.get("identifying_keywords", []) ) post_types.append(post_type) if post_types: await db.create_post_types_bulk(post_types) logger.info(f"Created {len(post_types)} post types for user") # Update status to completed (skip categorization - done in background) if session.user_id: await db.update_user(UUID(session.user_id), {"onboarding_status": "completed"}) session.onboarding_status = "completed" response = RedirectResponse(url="/onboarding/complete", status_code=302) set_user_session(response, session) return response except Exception as e: logger.error(f"Error saving post types: {e}") return templates.TemplateResponse("onboarding/post_types.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("post_types"), "error": str(e) }) @user_router.get("/onboarding/categorize", response_class=HTMLResponse) async def onboarding_categorize_page(request: Request): """Post categorization page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if not session.user_id: return RedirectResponse(url="/onboarding/profile", status_code=302) user_id = UUID(session.user_id) profile = await db.get_profile(user_id) all_posts = await db.get_linkedin_posts(user_id) post_types = await db.get_post_types(user_id) # Count posts per type type_counts = {} for pt in post_types: posts_for_type = await db.get_posts_by_type(user_id, pt.id) type_counts[str(pt.id)] = len(posts_for_type) # Get uncategorized posts uncategorized = await db.get_unclassified_posts(user_id) classified_count = len(all_posts) - len(uncategorized) total_posts = len(all_posts) progress = int((classified_count / total_posts * 100)) if total_posts > 0 else 0 post_types_with_counts = [ {"id": str(pt.id), "name": pt.name, "count": type_counts.get(str(pt.id), 0)} for pt in post_types ] return templates.TemplateResponse("onboarding/categorize.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("categorize"), "profile": profile, "post_types": post_types_with_counts, "total_posts": total_posts, "classified_count": classified_count, "uncategorized_count": len(uncategorized), "uncategorized_posts": uncategorized[:5], "progress": progress, "classification_complete": len(uncategorized) == 0 or classified_count >= 3 }) @user_router.post("/onboarding/categorize") async def onboarding_categorize_submit(request: Request): """Complete categorization and move to final step.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Run profile analysis if session.user_id: try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) posts = await db.get_linkedin_posts(user_id) # Create profile and run analysis from src.database.models import LinkedInProfile, ProfileAnalysis linkedin_profile = LinkedInProfile( user_id=user_id, profile_data={"linkedin_url": profile.linkedin_url}, name=profile.name ) await db.save_linkedin_profile(linkedin_profile) # Run analysis profile_analysis = await orchestrator.profile_analyzer.process( profile=linkedin_profile, posts=posts, customer_data=profile.metadata ) analysis_record = ProfileAnalysis( user_id=user_id, writing_style=profile_analysis.get("writing_style", {}), tone_analysis=profile_analysis.get("tone_analysis", {}), topic_patterns=profile_analysis.get("topic_patterns", {}), audience_insights=profile_analysis.get("audience_insights", {}), full_analysis=profile_analysis ) await db.save_profile_analysis(analysis_record) logger.info("Profile analysis completed during onboarding") except Exception as e: logger.error(f"Profile analysis failed during onboarding: {e}") response = RedirectResponse(url="/onboarding/complete", status_code=302) set_user_session(response, session) return response @user_router.get("/onboarding/complete", response_class=HTMLResponse) async def onboarding_complete_page(request: Request, background_tasks: BackgroundTasks): """Onboarding completion page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Mark onboarding as complete if session.user_id: await db.update_user(UUID(session.user_id), {"onboarding_status": "completed"}) session.onboarding_status = "completed" customer = None posts_count = 0 post_types_count = 0 profile_analysis = None analysis_started = False if session.user_id: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) posts = await db.get_linkedin_posts(user_id) posts_count = len(posts) post_types = await db.get_post_types(user_id) post_types_count = len(post_types) profile_analysis = await db.get_profile_analysis(user_id) # Start background analysis if not already done if not profile_analysis: # Check if there's no active analysis job active_jobs = job_manager.get_active_jobs(session.user_id) if not active_jobs: # Start full analysis pipeline in background background_tasks.add_task(run_full_analysis_pipeline, user_id) analysis_started = True logger.info(f"Started background analysis pipeline for user {user_id}") response = templates.TemplateResponse("onboarding/complete.html", { "request": request, "session": session, "steps": get_ghostwriter_steps("complete"), "profile": profile, "posts_count": posts_count, "post_types_count": post_types_count, "profile_analysis": profile_analysis, "analysis_started": analysis_started }) set_user_session(response, session) return response # ==================== COMPANY ONBOARDING ROUTES ==================== @user_router.get("/onboarding/company", response_class=HTMLResponse) async def onboarding_company_page(request: Request): """Company data setup page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company": return RedirectResponse(url="/onboarding/profile", status_code=302) company = None if session.company_id: company = await db.get_company(UUID(session.company_id)) return templates.TemplateResponse("onboarding/company.html", { "request": request, "session": session, "steps": get_company_steps("company"), "company": company }) @user_router.post("/onboarding/company") async def onboarding_company_submit( request: Request, name: str = Form(...), description: str = Form(""), website: str = Form(""), industry: str = Form("") ): """Save company data and proceed to strategy.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: if session.company_id: await db.update_company(UUID(session.company_id), { "name": name, "description": description or None, "website": website or None, "industry": industry or None }) session.company_name = name response = RedirectResponse(url="/onboarding/strategy", status_code=302) set_user_session(response, session) return response except Exception as e: logger.error(f"Error saving company data: {e}") return templates.TemplateResponse("onboarding/company.html", { "request": request, "session": session, "steps": get_company_steps("company"), "error": str(e) }) @user_router.get("/onboarding/strategy", response_class=HTMLResponse) async def onboarding_strategy_page(request: Request): """Company strategy setup page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) company = None strategy = {} if session.company_id: company = await db.get_company(UUID(session.company_id)) if company: strategy = company.company_strategy or {} return templates.TemplateResponse("onboarding/strategy.html", { "request": request, "session": session, "steps": get_company_steps("strategy"), "company": company, "strategy": strategy }) @user_router.post("/onboarding/strategy") async def onboarding_strategy_submit(request: Request): """Save company strategy and complete onboarding.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) form = await request.form() # Build strategy object strategy = { "mission": form.get("mission", ""), "vision": form.get("vision", ""), "brand_voice": form.get("brand_voice", ""), "tone_guidelines": form.get("tone_guidelines", ""), "target_audience": form.get("target_audience", ""), "content_pillars": [p for p in form.getlist("content_pillar") if p], "dos": [d for d in form.getlist("do_item") if d], "donts": [d for d in form.getlist("dont_item") if d] } try: if session.company_id: await db.update_company(UUID(session.company_id), { "company_strategy": strategy, "onboarding_completed": True }) # Mark user onboarding as complete if session.user_id: await db.update_user(UUID(session.user_id), {"onboarding_status": "completed"}) session.onboarding_status = "completed" response = RedirectResponse(url="/", status_code=302) set_user_session(response, session) return response except Exception as e: logger.error(f"Error saving company strategy: {e}") company = await db.get_company(UUID(session.company_id)) if session.company_id else None return templates.TemplateResponse("onboarding/strategy.html", { "request": request, "session": session, "steps": get_company_steps("strategy"), "company": company, "strategy": strategy, "error": str(e) }) # ==================== PROTECTED PAGES ==================== @user_router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): """User dashboard - shows only their own stats.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Check if onboarding is complete if not session.is_onboarding_complete: # Redirect to appropriate onboarding step based on account type if session.account_type == "company": # Company accounts need company onboarding if not session.company_id: return RedirectResponse(url="/onboarding/company", status_code=302) # Check if company onboarding is done if session.company_id: company = await db.get_company(UUID(session.company_id)) if company and not company.onboarding_completed: return RedirectResponse(url="/onboarding/strategy", status_code=302) return RedirectResponse(url="/onboarding/company", status_code=302) else: # Ghostwriter/Employee accounts need customer profile onboarding_status = session.onboarding_status if onboarding_status == "pending": return RedirectResponse(url="/onboarding/profile", status_code=302) elif onboarding_status == "profile_setup": return RedirectResponse(url="/onboarding/posts", status_code=302) elif onboarding_status == "posts_scraped": return RedirectResponse(url="/onboarding/post-types", status_code=302) elif onboarding_status == "categorizing": return RedirectResponse(url="/onboarding/post-types", status_code=302) else: return RedirectResponse(url="/onboarding/profile", status_code=302) # For ghostwriter/employee: Check if user_id exists if session.account_type != "company" and not session.user_id: # Reset onboarding status if it was marked complete but no customer if session.user_id: await db.update_profile(UUID(session.user_id), {"onboarding_status": "pending"}) session.onboarding_status = "pending" return RedirectResponse(url="/onboarding/profile", status_code=302) try: # Company accounts have a different dashboard if session.account_type == "company": # Fetch company data with error handling for missing companies company = None employees_raw = [] pending_invitations = [] quota = None license_key = None if session.company_id: try: company_id_uuid = UUID(session.company_id) company, employees_raw, pending_invitations, quota, license_key = await asyncio.gather( db.get_company(company_id_uuid), db.get_company_employees(company_id_uuid), db.get_pending_invitations(company_id_uuid), db.get_company_daily_quota(company_id_uuid), db.get_company_limits(company_id_uuid), ) except Exception as company_error: logger.warning(f"Could not load company data for {session.company_id}: {company_error}") # Continue without company data - better than crashing # Add avatar URLs to employees (parallel) def _make_emp_session(emp): return UserSession( user_id=str(emp.id), linkedin_picture=emp.linkedin_picture, email=emp.email, account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type, display_name=emp.display_name ) emp_sessions = [_make_emp_session(emp) for emp in employees_raw] avatar_urls = await asyncio.gather(*[get_user_avatar(s, emp.id) for s, emp in zip(emp_sessions, employees_raw)]) employees = [ { "id": emp.id, "email": emp.email, "display_name": emp.display_name or emp.linkedin_name or emp.email, "onboarding_status": emp.onboarding_status, "avatar_url": avatar_url } for emp, avatar_url in zip(employees_raw, avatar_urls) ] user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_dashboard.html", { "request": request, "page": "home", "session": session, "company": company, "employees": employees, "total_employees": len(employees), "pending_invitations": pending_invitations, "quota": quota, "license_key": license_key, "profile_picture": profile_picture }) # Employee accounts have their own dashboard user_id = UUID(session.user_id) profile = await db.get_profile(user_id) posts = await db.get_generated_posts(user_id) profile_picture = await get_user_avatar(session, user_id) if session.account_type == "employee": # Count post statuses pending_posts = len([p for p in posts if p.status in ['draft', 'pending']]) approved_posts = len([p for p in posts if p.status in ['approved', 'ready']]) return templates.TemplateResponse("employee_dashboard.html", { "request": request, "page": "home", "session": session, "profile": profile, "posts_count": len(posts), "pending_posts": pending_posts, "approved_posts": approved_posts, "recent_posts": posts[:5] if posts else [], "profile_picture": profile_picture }) # Ghostwriter accounts return templates.TemplateResponse("dashboard.html", { "request": request, "page": "home", "session": session, "profile": profile, "total_posts": len(posts), "profile_picture": profile_picture }) except Exception as e: logger.error(f"Error loading dashboard: {e}") template = "employee_dashboard.html" if session.account_type == "employee" else "dashboard.html" return templates.TemplateResponse(template, { "request": request, "page": "home", "session": session, "error": str(e) }) _ACTIVE_STATUSES = {"draft", "approved", "ready"} _ARCHIVE_STATUSES = {"scheduled", "published", "rejected"} @user_router.get("/posts", response_class=HTMLResponse) async def posts_page(request: Request): """View user's own posts (active Kanban only).""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) all_posts = await db.get_generated_posts(user_id) active_posts = [p for p in all_posts if p.status in _ACTIVE_STATUSES] archived_count = sum(1 for p in all_posts if p.status in _ARCHIVE_STATUSES) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("posts.html", { "request": request, "page": "posts", "session": session, "profile": profile, "posts": active_posts, "total_posts": len(active_posts), "archived_count": archived_count, "profile_picture": profile_picture }) except Exception as e: import traceback logger.error(f"Error loading posts: {e}") logger.error(f"Traceback: {traceback.format_exc()}") return templates.TemplateResponse("posts.html", { "request": request, "page": "posts", "session": session, "posts": [], "total_posts": 0, "archived_count": 0, "error": str(e) }) @user_router.get("/posts/archive", response_class=HTMLResponse) async def posts_archive_page(request: Request, page: int = 1): """View archived posts (published, scheduled, rejected).""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: user_id = UUID(session.user_id) all_posts = await db.get_generated_posts(user_id) archived_posts = [p for p in all_posts if p.status in _ARCHIVE_STATUSES] # Sort: scheduled first (upcoming), then by published_at/created_at desc archived_posts.sort( key=lambda p: ( p.status != "scheduled", -(p.published_at or p.created_at or datetime.min.replace(tzinfo=timezone.utc)).timestamp() ) ) per_page = 20 total = len(archived_posts) total_pages = max(1, (total + per_page - 1) // per_page) page = max(1, min(page, total_pages)) start = (page - 1) * per_page page_posts = archived_posts[start:start + per_page] profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("posts_archive.html", { "request": request, "page": "posts", "session": session, "posts": page_posts, "total": total, "current_page": page, "total_pages": total_pages, "per_page": per_page, "profile_picture": profile_picture }) except Exception as e: logger.error(f"Error loading posts archive: {e}") return templates.TemplateResponse("posts_archive.html", { "request": request, "page": "posts", "session": session, "posts": [], "total": 0, "current_page": 1, "total_pages": 1, "per_page": 20, "error": str(e) }) @user_router.get("/post-types", response_class=HTMLResponse) async def post_types_page(request: Request): """Post types management page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: user_id = UUID(session.user_id) # Get post types post_types = await db.get_post_types(user_id) # Get all linkedin posts all_posts = await db.get_linkedin_posts(user_id) # Separate categorized and uncategorized uncategorized_posts = [p for p in all_posts if not p.post_type_id] categorized_posts = [p for p in all_posts if p.post_type_id] # Group categorized by type categorized_by_type = {} post_type_map = {pt.id: pt.name for pt in post_types} for post in categorized_posts: type_name = post_type_map.get(post.post_type_id, "Unbekannt") if type_name not in categorized_by_type: categorized_by_type[type_name] = [] categorized_by_type[type_name].append(post) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("post_types.html", { "request": request, "page": "post_types", "session": session, "post_types": post_types, "uncategorized_posts": uncategorized_posts, "categorized_posts": categorized_posts, "categorized_by_type": categorized_by_type, "profile_picture": profile_picture }) except Exception as e: logger.error(f"Error loading post types page: {e}") profile_picture = await get_user_avatar(session, UUID(session.user_id)) return templates.TemplateResponse("post_types.html", { "request": request, "page": "post_types", "session": session, "post_types": [], "uncategorized_posts": [], "categorized_posts": [], "categorized_by_type": {}, "error": str(e), "profile_picture": profile_picture }) @user_router.post("/api/categorize-post") async def api_categorize_post(request: Request): """Categorize a single post.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: data = await request.json() post_id = UUID(data["post_id"]) post_type_id = UUID(data["post_type_id"]) await db.update_linkedin_post(post_id, {"post_type_id": str(post_type_id)}) return JSONResponse({"success": True}) except Exception as e: logger.error(f"Error categorizing post: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.get("/api/job-updates") async def job_updates_sse(request: Request): """Server-Sent Events endpoint for job updates (Redis pub/sub — works across workers).""" session = require_user_session(request) tracking_id = getattr(session, 'user_id', None) or getattr(session, 'company_id', None) if not session or not tracking_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) async def event_generator(): from src.services.redis_client import get_redis r = await get_redis() pubsub = r.pubsub() await pubsub.subscribe(f"job_updates:{tracking_id}") try: # Send any currently active jobs as the initial state for job in await job_manager.get_active_jobs(tracking_id): data = { "id": job.id, "job_type": job.job_type.value, "status": job.status.value, "progress": job.progress, "message": job.message, "error": job.error, } yield f"data: {json.dumps(data)}\n\n" # Stream pub/sub messages, keepalive on timeout while True: try: msg = await asyncio.wait_for( pubsub.get_message(ignore_subscribe_messages=True), timeout=30 ) if msg and msg.get("type") == "message": yield f"data: {msg['data']}\n\n" else: yield ": keepalive\n\n" except asyncio.TimeoutError: yield ": keepalive\n\n" finally: await pubsub.unsubscribe(f"job_updates:{tracking_id}") await pubsub.aclose() return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @user_router.post("/api/run-post-type-analysis") async def api_run_post_type_analysis(request: Request, background_tasks: BackgroundTasks): """Start post type analysis in background.""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: user_id = UUID(session.user_id) # Create job job = await job_manager.create_job(JobType.POST_TYPE_ANALYSIS, session.user_id) # Run in background background_tasks.add_task(run_post_type_analysis, user_id, job.id) return JSONResponse({"success": True, "job_id": job.id}) except Exception as e: logger.error(f"Error starting post type analysis: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.post("/api/run-full-analysis") async def api_run_full_analysis(request: Request, background_tasks: BackgroundTasks): """Start full analysis pipeline in background (profile -> categorization -> post types).""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: user_id = UUID(session.user_id) # Run full pipeline in background background_tasks.add_task(run_full_analysis_pipeline, user_id) return JSONResponse({"success": True, "message": "Analysis pipeline started"}) except Exception as e: logger.error(f"Error starting full analysis: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.get("/api/jobs") async def api_get_jobs(request: Request): """Get all jobs for the current user.""" session = require_user_session(request) if not session or not session.user_id: return JSONResponse({"error": "Not authenticated"}, status_code=401) jobs = job_manager.get_user_jobs(session.user_id) return JSONResponse({ "jobs": [ { "id": j.id, "job_type": j.job_type.value, "status": j.status.value, "progress": j.progress, "message": j.message, "error": j.error, "created_at": j.created_at.isoformat() if j.created_at else None } for j in jobs ] }) @user_router.get("/posts/{post_id}", response_class=HTMLResponse) async def post_detail_page(request: Request, post_id: str): """Detailed view of a single post.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: post = await db.get_generated_post(UUID(post_id)) if not post: return RedirectResponse(url="/posts", status_code=302) # Verify user owns this post if str(post.user_id) != session.user_id: return RedirectResponse(url="/posts", status_code=302) profile, linkedin_posts, profile_picture_url, profile_analysis_record = await asyncio.gather( db.get_profile(post.user_id), db.get_linkedin_posts(post.user_id), get_user_avatar(session, post.user_id), db.get_profile_analysis(post.user_id), ) reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10] profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None post_type = None post_type_analysis = None if post.post_type_id: post_type = await db.get_post_type(post.post_type_id) if post_type and post_type.analysis: post_type_analysis = post_type.analysis final_feedback = None if post.critic_feedback and len(post.critic_feedback) > 0: final_feedback = post.critic_feedback[-1] # Convert media_items to dicts for JSON serialization in template media_items_dict = [] if post.media_items: media_items_dict = [ item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item) for item in post.media_items ] # Check token limit for quick action buttons limit_reached = False limit_message = "" if session.account_type in ("company", "employee") and session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_proceed limit_message = error_msg return templates.TemplateResponse("post_detail.html", { "request": request, "page": "posts", "session": session, "post": post, "profile": profile, "reference_posts": reference_posts, "profile_analysis": profile_analysis, "post_type": post_type, "post_type_analysis": post_type_analysis, "final_feedback": final_feedback, "profile_picture_url": profile_picture_url, "profile_picture": profile_picture_url, "media_items_dict": media_items_dict, "limit_reached": limit_reached, "limit_message": limit_message }) except Exception as e: import traceback logger.error(f"Error loading post detail: {e}") logger.error(f"Traceback: {traceback.format_exc()}") return RedirectResponse(url="/posts", status_code=302) @user_router.get("/research", response_class=HTMLResponse) async def research_page(request: Request): """Research topics page - with limit check for companies.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Check token limit for companies/employees limit_reached = False limit_message = "" if session.account_type in ("company", "employee") and session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("research.html", { "request": request, "page": "research", "session": session, "user_id": session.user_id, "limit_reached": limit_reached, "limit_message": limit_message, "profile_picture": profile_picture }) @user_router.get("/create", response_class=HTMLResponse) async def create_post_page(request: Request): """Create post page - with limit check for companies.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Check token limit for companies/employees limit_reached = False limit_message = "" if session.account_type in ("company", "employee") and session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("create_post.html", { "request": request, "page": "create", "session": session, "user_id": session.user_id, "limit_reached": limit_reached, "limit_message": limit_message, "profile_picture": profile_picture }) @user_router.get("/chat-create", response_class=HTMLResponse) async def chat_create_page(request: Request): """Chat-based post creation page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) user_id = UUID(session.user_id) # Check token limit for companies/employees limit_reached = False limit_message = "" if session.account_type in ("company", "employee") and session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg # Get post types post_types = await db.get_post_types(user_id) if not post_types: return templates.TemplateResponse("error.html", { "request": request, "session": session, "error": "Keine Post-Typen gefunden. Bitte erstelle zuerst Post-Typen." }) profile_picture = await get_user_avatar(session, user_id) # Load all saved posts for sidebar (exclude scheduled and published) all_posts = await db.get_generated_posts(user_id) saved_posts = [post for post in all_posts if post.status not in ['scheduled', 'published']] return templates.TemplateResponse("chat_create.html", { "request": request, "page": "chat-create", "session": session, "post_types": post_types, "profile_picture": profile_picture, "saved_posts": saved_posts, "limit_reached": limit_reached, "limit_message": limit_message }) @user_router.get("/status", response_class=HTMLResponse) async def status_page(request: Request): """User's status page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) status = await orchestrator.get_user_status(user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("status.html", { "request": request, "page": "status", "session": session, "profile": profile, "status": status, "profile_picture": profile_picture }) except Exception as e: logger.error(f"Error loading status: {e}") return templates.TemplateResponse("status.html", { "request": request, "page": "status", "session": session, "error": str(e) }) # ==================== API ENDPOINTS ==================== @user_router.get("/api/post-types") async def get_post_types(request: Request, user_id: str = None): """Get post types for the logged-in user or specified user (for company owners).""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: # Company owners can specify a user_id for their employees target_user_id = user_id if user_id and session.account_type == "company" else session.user_id if not target_user_id: return {"post_types": []} post_types = await db.get_post_types(UUID(target_user_id)) return { "post_types": [ { "id": str(pt.id), "name": pt.name, "description": pt.description, "has_analysis": pt.analysis is not None, "analyzed_post_count": pt.analyzed_post_count, } for pt in post_types ] } except Exception as e: logger.error(f"Error loading post types: {e}") return {"post_types": [], "error": str(e)} @user_router.get("/api/topics") async def get_topics(request: Request, post_type_id: str = None, user_id: str = None): """Get research topics for the logged-in user or specified user (for company owners).""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: # Company owners can specify a user_id for their employees target_user_id = user_id if user_id and session.account_type == "company" else session.user_id if not target_user_id: return {"topics": [], "available_count": 0, "used_count": 0} user_id = UUID(target_user_id) if post_type_id: all_research = await db.get_all_research(user_id, UUID(post_type_id)) else: all_research = await db.get_all_research(user_id) # Get used topics generated_posts = await db.get_generated_posts(user_id) used_topic_titles = set() for post in generated_posts: if post.topic_title: used_topic_titles.add(post.topic_title.lower().strip()) all_topics = [] for research in all_research: if research.suggested_topics: for topic in research.suggested_topics: topic_title = topic.get("title", "").lower().strip() if topic_title in used_topic_titles: continue topic["research_id"] = str(research.id) topic["target_post_type_id"] = str(research.target_post_type_id) if research.target_post_type_id else None all_topics.append(topic) return {"topics": all_topics, "used_count": len(used_topic_titles), "available_count": len(all_topics)} except Exception as e: logger.error(f"Error loading topics: {e}") return {"topics": [], "error": str(e)} @user_router.get("/api/tasks/{task_id}") async def get_task_status(task_id: str): """Get task progress.""" return progress_store.get(task_id, {"status": "unknown", "message": "Task not found"}) @user_router.post("/api/research") async def start_research(request: Request, background_tasks: BackgroundTasks, post_type_id: str = Form(None), user_id: str = Form(None)): """Start research for the logged-in user or specified user (for company owners).""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") # CHECK COMPANY TOKEN LIMIT if session.account_type in ("company", "employee") and session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_create: raise HTTPException(status_code=429, detail=error_msg) # Company owners can specify a user_id for their employees target_user_id = user_id if user_id and session.account_type == "company" else session.user_id if not target_user_id: raise HTTPException(status_code=400, detail="No user ID available") user_id = target_user_id task_id = f"research_{user_id}_{asyncio.get_event_loop().time()}" progress_store[task_id] = {"status": "starting", "message": "Starte Recherche...", "progress": 0} async def run_research(): try: def progress_callback(message: str, step: int, total: int): progress_store[task_id] = {"status": "running", "message": message, "progress": int((step / total) * 100)} topics = await orchestrator.research_new_topics( UUID(user_id), progress_callback=progress_callback, post_type_id=UUID(post_type_id) if post_type_id else None ) progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics} except Exception as e: logger.exception(f"Research failed: {e}") progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0} background_tasks.add_task(run_research) return {"task_id": task_id} @user_router.post("/api/transcribe") async def transcribe_audio(request: Request): """Transcribe audio using OpenAI Whisper.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") import openai import tempfile import os try: client = openai.OpenAI(api_key=settings.openai_api_key) # Get the uploaded file from the request form = await request.form() audio_file = form.get("audio") if not audio_file: raise HTTPException(status_code=400, detail="No audio file provided") # Read the audio content audio_content = await audio_file.read() # Save to temporary file (Whisper needs a file) with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp_file: tmp_file.write(audio_content) tmp_path = tmp_file.name try: # Transcribe with Whisper with open(tmp_path, "rb") as f: transcript = client.audio.transcriptions.create( model="whisper-1", file=f, language="de", response_format="text" ) return {"text": transcript} finally: # Clean up temp file os.unlink(tmp_path) except HTTPException: raise except Exception as e: logger.exception(f"Transcription failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/hooks") async def generate_hooks( request: Request, topic_json: str = Form(...), user_thoughts: str = Form(""), post_type_id: str = Form(None), user_id: str = Form(None) ): """Generate hook options for a topic.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: topic = json.loads(topic_json) # Company owners can specify a user_id for their employees target_user_id = user_id if user_id and session.account_type == "company" else session.user_id if not target_user_id: raise HTTPException(status_code=400, detail="No user ID available") user_id = UUID(target_user_id) hooks = await orchestrator.generate_hooks( user_id=user_id, topic=topic, user_thoughts=user_thoughts, post_type_id=UUID(post_type_id) if post_type_id else None ) return {"hooks": hooks} except Exception as e: logger.exception(f"Hook generation failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/posts") async def create_post( request: Request, background_tasks: BackgroundTasks, topic_json: str = Form(...), post_type_id: str = Form(None), user_thoughts: str = Form(""), selected_hook: str = Form(""), user_id: str = Form(None) ): """Create a new post for the logged-in user or specified user (for company owners).""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") # CHECK COMPANY TOKEN LIMIT if session.account_type in ("company", "employee") and session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_create: raise HTTPException(status_code=429, detail=error_msg) # Company owners can specify a user_id for their employees target_user_id = user_id if user_id and session.account_type == "company" else session.user_id if not target_user_id: raise HTTPException(status_code=400, detail="No user ID available") user_id = target_user_id task_id = f"post_{user_id}_{asyncio.get_event_loop().time()}" progress_store[task_id] = {"status": "starting", "message": "Starte Post-Erstellung...", "progress": 0} topic = json.loads(topic_json) async def run_create_post(): try: def progress_callback(message: str, iteration: int, max_iterations: int, score: int = None, versions: list = None, feedback_list: list = None): progress = int((iteration / max_iterations) * 100) if iteration > 0 else 5 score_text = f" (Score: {score}/100)" if score else "" progress_store[task_id] = { "status": "running", "message": f"{message}{score_text}", "progress": progress, "iteration": iteration, "max_iterations": max_iterations, "versions": versions or [], "feedback_list": feedback_list or [] } result = await orchestrator.create_post( user_id=UUID(user_id), topic=topic, max_iterations=3, progress_callback=progress_callback, post_type_id=UUID(post_type_id) if post_type_id else None, user_thoughts=user_thoughts, selected_hook=selected_hook ) progress_store[task_id] = { "status": "completed", "message": "Post erstellt!", "progress": 100, "result": { "post_id": str(result["post_id"]), "final_post": result["final_post"], "iterations": result["iterations"], "final_score": result["final_score"], "approved": result["approved"] } } except Exception as e: logger.exception(f"Post creation failed: {e}") progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0} background_tasks.add_task(run_create_post) return {"task_id": task_id} @user_router.get("/api/posts/{post_id}/suggestions") async def get_post_suggestions(request: Request, post_id: str): """Get AI improvement suggestions for a post based on critic feedback.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") # Check token limit for companies/employees if session.account_type in ("company", "employee") and session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_proceed: raise HTTPException(status_code=429, detail=error_msg) try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Verify authorization: post owner or company with can_edit_posts is_owner = str(post.user_id) == session.user_id is_authorized = is_owner if not is_owner and session.account_type == "company" and session.company_id: profile_check = await db.get_profile(post.user_id) if profile_check and profile_check.company_id and str(profile_check.company_id) == session.company_id: edit_perms = await get_employee_permissions_or_default(post.user_id, UUID(session.company_id)) if edit_perms.get("can_edit_posts", True): is_authorized = True if not is_authorized: raise HTTPException(status_code=403, detail="Not authorized") # Get the last critic feedback if available critic_feedback = None if post.critic_feedback and len(post.critic_feedback) > 0: critic_feedback = post.critic_feedback[-1] suggestions = await orchestrator.generate_improvement_suggestions( user_id=UUID(session.user_id), post_content=post.post_content, critic_feedback=critic_feedback ) return {"suggestions": suggestions} except HTTPException: raise except Exception as e: logger.exception(f"Failed to generate suggestions: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/posts/{post_id}/revise") async def revise_post( request: Request, post_id: str, suggestion: str = Form(...) ): """Apply a suggestion to a post and save as new version.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") # Check token limit for companies/employees if session.account_type in ("company", "employee") and session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_proceed: raise HTTPException(status_code=429, detail=error_msg) try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Verify authorization: post owner or company with can_edit_posts is_owner = str(post.user_id) == session.user_id is_authorized = is_owner if not is_owner and session.account_type == "company" and session.company_id: profile_check = await db.get_profile(post.user_id) if profile_check and profile_check.company_id and str(profile_check.company_id) == session.company_id: edit_perms = await get_employee_permissions_or_default(post.user_id, UUID(session.company_id)) if edit_perms.get("can_edit_posts", True): is_authorized = True if not is_authorized: raise HTTPException(status_code=403, detail="Not authorized") # Apply the suggestion improved_content = await orchestrator.apply_suggestion_to_post( user_id=UUID(session.user_id), post_content=post.post_content, suggestion=suggestion ) # Save as new version without score writer_versions = post.writer_versions or [] writer_versions.append(improved_content) # Update post with new content and version updated_post = await db.update_generated_post( UUID(post_id), { "post_content": improved_content, "writer_versions": writer_versions } ) return { "success": True, "new_content": improved_content, "version_count": len(writer_versions) } except HTTPException: raise except Exception as e: logger.exception(f"Failed to revise post: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.patch("/api/posts/{post_id}/status") async def update_post_status( request: Request, post_id: str, status: str = Form(...) ): """Update post status (for Kanban board). Sends email when moving to 'approved'.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") # Validate status valid_statuses = ["draft", "approved", "ready", "scheduled", "published"] if status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Verify user owns this post or is a company owner of the employee is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: # Check if this post belongs to an employee of this company profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") # Company cannot set status to "ready" — only the employee/owner can if status == "ready" and is_company_owner: raise HTTPException(status_code=403, detail="Nur Mitarbeiter können Posts freigeben") # Get profile for email settings profile = await db.get_profile(post.user_id) # Update status await db.update_generated_post(UUID(post_id), {"status": status}) base_url = str(request.base_url).rstrip('/') email_sent = False if status == "approved": if is_company_owner: # Company moved post to "approved": send approval request to employee (creator_email) company = await db.get_company(UUID(session.company_id)) company_name = company.name if company else (session.company_name or "Ihr Unternehmen") # Generate tokens once — shared by email and Telegram buttons approve_token = await generate_token(UUID(post_id), "approve") reject_token = await generate_token(UUID(post_id), "reject") approve_url = f"{base_url}/api/email-action/{approve_token}" reject_url = f"{base_url}/api/email-action/{reject_token}" if profile and profile.creator_email: email_sent = send_company_approval_request_email( to_email=profile.creator_email, post_title=post.topic_title or "Untitled Post", post_content=post.post_content or "", company_name=company_name, approve_url=approve_url, reject_url=reject_url, image_url=post.image_url ) # Send Telegram notification with inline approve/reject buttons if settings.telegram_enabled: try: from src.services.telegram_service import telegram_service as _tg telegram_account = await db.get_telegram_account(post.user_id) if _tg and telegram_account and telegram_account.is_active: await _tg.send_message( chat_id=telegram_account.telegram_chat_id, text=( f"📋 {company_name} hat deinen Post bearbeitet und bittet um deine Freigabe:\n\n" f"{post.topic_title or 'Untitled Post'}" ), reply_markup={ "inline_keyboard": [[ {"text": "✅ Freigeben", "callback_data": f"approve:{approve_token}"}, {"text": "❌ Ablehnen", "callback_data": f"reject:{reject_token}"} ]] } ) except Exception as tg_err: logger.warning(f"Telegram notification failed: {tg_err}") else: # Employee/owner moving their own post to "approved": no email/telegram notification pass return {"success": True, "status": status, "email_sent": email_sent} except HTTPException: raise except Exception as e: logger.exception(f"Failed to update post status: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.put("/api/posts/{post_id}") async def update_post( request: Request, post_id: str, content: str = Form(...) ): """Manually update post content and save as new version.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Verify user owns this post OR is a company owner with edit permission is_owner = str(post.user_id) == session.user_id is_authorized = is_owner if not is_owner and session.account_type == "company" and session.company_id: profile_check = await db.get_profile(post.user_id) if profile_check and profile_check.company_id and str(profile_check.company_id) == session.company_id: edit_perms = await get_employee_permissions_or_default(post.user_id, UUID(session.company_id)) if edit_perms.get("can_edit_posts", True): is_authorized = True if not is_authorized: raise HTTPException(status_code=403, detail="Not authorized") if len(content) > 3000: raise HTTPException(status_code=422, detail="Post überschreitet 3000-Zeichen-Limit") # Save as new version writer_versions = post.writer_versions or [] writer_versions.append(content) # Update post with new content and version updated_post = await db.update_generated_post( UUID(post_id), { "post_content": content, "writer_versions": writer_versions } ) return { "success": True, "new_content": content, "version_count": len(writer_versions) } except HTTPException: raise except Exception as e: logger.exception(f"Failed to update post: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== EMAIL ACTION ENDPOINTS ==================== @user_router.get("/api/email-action/{token}", response_class=HTMLResponse) async def handle_email_action(request: Request, token: str): """Handle email action (approve/reject) from email link.""" token_data = await validate_token(token) if not token_data: return templates.TemplateResponse("email_action_result.html", { "request": request, "success": False, "message": "Dieser Link ist ungültig oder abgelaufen.", "title": "Link ungültig" }) post_id = UUID(token_data["post_id"]) action = token_data["action"] try: post = await db.get_generated_post(post_id) if not post: return templates.TemplateResponse("email_action_result.html", { "request": request, "success": False, "message": "Der Post wurde nicht gefunden.", "title": "Post nicht gefunden" }) profile = await db.get_profile(post.user_id) # Determine new status based on action if action == "approve": new_status = "ready" decision = "approved" message = "Der Post wurde freigegeben und kann nun im Kalender eingeplant werden." title = "Post freigegeben" else: # reject new_status = "draft" decision = "rejected" message = "Der Post wurde zur Überarbeitung zurückgeschickt." title = "Zurück zur Überarbeitung" # Update post status await db.update_generated_post(post_id, {"status": new_status}) # Invalidate ALL tokens for this post (both approve + reject) await db.mark_all_post_tokens_used(post_id) # Send notification to creator if profile and profile.creator_email: base_url = str(request.base_url).rstrip('/') send_decision_notification_email( to_email=profile.creator_email, post_title=post.topic_title or "Untitled Post", decision=decision, base_url=base_url, post_id=post_id, image_url=post.image_url ) return templates.TemplateResponse("email_action_result.html", { "request": request, "success": True, "message": message, "title": title, "action": action }) except Exception as e: logger.exception(f"Failed to process email action: {e}") return templates.TemplateResponse("email_action_result.html", { "request": request, "success": False, "message": "Ein Fehler ist aufgetreten. Bitte versuche es später erneut.", "title": "Fehler" }) # ==================== SETTINGS ENDPOINTS ==================== @user_router.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """User settings page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) try: user_id = UUID(session.user_id) profile = await db.get_profile(user_id) profile_picture = await get_user_avatar(session, user_id) # Get LinkedIn account if linked linkedin_account = await db.get_linkedin_account(user_id) # Get Telegram account if feature is enabled telegram_account = None if settings.telegram_enabled: telegram_account = await db.get_telegram_account(user_id) # Get company permissions if user is an employee with a company company_permissions = None company = None if session.account_type == "employee" and session.company_id: company_permissions = await get_employee_permissions_or_default(user_id, UUID(session.company_id)) company = await db.get_company(UUID(session.company_id)) return templates.TemplateResponse("settings.html", { "request": request, "page": "settings", "session": session, "profile": profile, "profile_picture": profile_picture, "linkedin_account": linkedin_account, "telegram_enabled": settings.telegram_enabled, "telegram_account": telegram_account, "company_permissions": company_permissions, "company": company, }) except Exception as e: logger.error(f"Error loading settings: {e}") return templates.TemplateResponse("settings.html", { "request": request, "page": "settings", "session": session, "error": str(e) }) @user_router.post("/api/settings/emails") async def update_email_settings( request: Request, creator_email: str = Form(""), customer_email: str = Form("") ): """Update email settings for the customer.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: # Update profile with new email settings await db.update_profile( UUID(session.user_id), { "creator_email": creator_email if creator_email else None, "customer_email": customer_email if customer_email else None } ) return {"success": True} except Exception as e: logger.exception(f"Failed to update email settings: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/settings/company-permissions") async def update_company_permissions(request: Request): """Update employee-company permissions. Checkboxes present = True, absent = False.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "employee" or not session.company_id: raise HTTPException(status_code=403, detail="Only employees can update company permissions") try: form = await request.form() updates = { "can_create_posts": "can_create_posts" in form, "can_view_posts": "can_view_posts" in form, "can_edit_posts": "can_edit_posts" in form, "can_schedule_posts": "can_schedule_posts" in form, "can_manage_post_types": "can_manage_post_types" in form, "can_do_research": "can_do_research" in form, "can_see_in_calendar": "can_see_in_calendar" in form, } await db.upsert_employee_permissions( UUID(session.user_id), UUID(session.company_id), updates ) return {"success": True} except Exception as e: logger.exception(f"Failed to update company permissions: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== LINKEDIN ACCOUNT LINKING ==================== @user_router.get("/settings/linkedin/connect") async def linkedin_connect(request: Request): """Initiate LinkedIn OAuth for account linking.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Only employees can link LinkedIn accounts if session.account_type not in ["employee", "ghostwriter"]: return RedirectResponse(url="/settings", status_code=302) # Generate CSRF state token import secrets state = secrets.token_urlsafe(32) # Build LinkedIn OAuth URL from urllib.parse import urlencode params = { "response_type": "code", "client_id": settings.linkedin_client_id, "redirect_uri": settings.linkedin_redirect_uri, "state": state, "scope": "openid profile email w_member_social" } oauth_url = f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}" # Store state in cookie for verification response = RedirectResponse(url=oauth_url, status_code=302) response.set_cookie("linkedin_oauth_state", state, max_age=600, httponly=True, secure=True, samesite="lax") return response @user_router.get("/settings/linkedin/callback") async def linkedin_callback( request: Request, code: str = "", state: str = "", error: str = "" ): """Handle LinkedIn OAuth callback.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login?error=auth_required", status_code=302) # Check for OAuth errors if error: logger.error(f"LinkedIn OAuth error: {error}") return RedirectResponse(url="/settings?error=linkedin_auth_failed", status_code=302) # Verify CSRF state stored_state = request.cookies.get("linkedin_oauth_state") if not stored_state or stored_state != state: logger.error("LinkedIn OAuth state mismatch") return RedirectResponse(url="/settings?error=invalid_state", status_code=302) try: # Exchange code for access token import httpx from datetime import timedelta from src.database.models import LinkedInAccount from src.utils.encryption import encrypt_token async with httpx.AsyncClient() as client: token_response = await client.post( "https://www.linkedin.com/oauth/v2/accessToken", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": settings.linkedin_redirect_uri, "client_id": settings.linkedin_client_id, "client_secret": settings.linkedin_client_secret }, headers={"Content-Type": "application/x-www-form-urlencoded"} ) if token_response.status_code != 200: logger.error(f"Token exchange failed: {token_response.status_code} - {token_response.text}") return RedirectResponse(url="/settings?error=token_exchange_failed", status_code=302) token_data = token_response.json() access_token = token_data["access_token"] expires_in = token_data.get("expires_in", 5184000) # Default 60 days refresh_token = token_data.get("refresh_token") # May not be provided scope = token_data.get("scope", "") # Get LinkedIn user info userinfo_response = await client.get( "https://api.linkedin.com/v2/userinfo", headers={"Authorization": f"Bearer {access_token}"} ) if userinfo_response.status_code != 200: logger.error(f"Userinfo fetch failed: {userinfo_response.status_code}") return RedirectResponse(url="/settings?error=userinfo_failed", status_code=302) userinfo = userinfo_response.json() # Extract LinkedIn user data linkedin_user_id = userinfo.get("sub") linkedin_name = userinfo.get("name", "") linkedin_picture = userinfo.get("picture") # Get vanity name if available (from profile API - optional) linkedin_vanity_name = None try: profile_response = await client.get( "https://api.linkedin.com/v2/me", headers={"Authorization": f"Bearer {access_token}"} ) if profile_response.status_code == 200: profile_data = profile_response.json() linkedin_vanity_name = profile_data.get("vanityName") except Exception as e: logger.warning(f"Could not fetch vanity name: {e}") # Encrypt tokens encrypted_access = encrypt_token(access_token) encrypted_refresh = encrypt_token(refresh_token) if refresh_token else None # Check if account already exists existing_account = await db.get_linkedin_account(UUID(session.user_id)) if existing_account: # Update existing account await db.update_linkedin_account( existing_account.id, { "linkedin_user_id": linkedin_user_id, "linkedin_vanity_name": linkedin_vanity_name, "linkedin_name": linkedin_name, "linkedin_picture": linkedin_picture, "access_token": encrypted_access, "refresh_token": encrypted_refresh, "token_expires_at": datetime.now(timezone.utc) + timedelta(seconds=expires_in), "granted_scopes": scope.split() if scope else [], "is_active": True, "last_error": None, "last_error_at": None } ) logger.info(f"Updated LinkedIn account for user {session.user_id}") else: # Create new account new_account = LinkedInAccount( user_id=UUID(session.user_id), linkedin_user_id=linkedin_user_id, linkedin_vanity_name=linkedin_vanity_name, linkedin_name=linkedin_name, linkedin_picture=linkedin_picture, access_token=encrypted_access, refresh_token=encrypted_refresh, token_expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in), granted_scopes=scope.split() if scope else [] ) await db.create_linkedin_account(new_account) logger.info(f"Created LinkedIn account for user {session.user_id}") # Clear state cookie and redirect to settings response = RedirectResponse(url="/settings?success=linkedin_connected", status_code=302) response.delete_cookie("linkedin_oauth_state") return response except Exception as e: logger.exception(f"LinkedIn OAuth callback error: {e}") return RedirectResponse(url="/settings?error=connection_failed", status_code=302) @user_router.post("/api/settings/linkedin/disconnect") async def linkedin_disconnect(request: Request): """Remove LinkedIn account connection.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: # Get account linkedin_account = await db.get_linkedin_account(UUID(session.user_id)) if not linkedin_account: return {"success": False, "error": "No LinkedIn account found"} # Optional: Revoke token with LinkedIn (not strictly necessary) # LinkedIn doesn't have a reliable revocation endpoint, so we just delete from DB # Delete from database await db.delete_linkedin_account(linkedin_account.id) logger.info(f"Disconnected LinkedIn account for user {session.user_id}") return {"success": True} except Exception as e: logger.exception(f"Failed to disconnect LinkedIn: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== COMPANY MANAGEMENT ENDPOINTS ==================== @user_router.get("/company/strategy", response_class=HTMLResponse) async def company_strategy_page(request: Request, success: bool = False): """Company strategy editing page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) company = await db.get_company(UUID(session.company_id)) strategy = company.company_strategy if company else {} user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_strategy.html", { "request": request, "page": "strategy", "session": session, "company": company, "strategy": strategy, "success": success, "profile_picture": profile_picture }) @user_router.post("/company/strategy") async def company_strategy_submit(request: Request): """Save company strategy.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) form = await request.form() strategy = { "mission": form.get("mission", ""), "vision": form.get("vision", ""), "brand_voice": form.get("brand_voice", ""), "tone_guidelines": form.get("tone_guidelines", ""), "target_audience": form.get("target_audience", ""), "content_pillars": [p for p in form.getlist("content_pillar") if p], "dos": [d for d in form.getlist("do_item") if d], "donts": [d for d in form.getlist("dont_item") if d] } try: await db.update_company(UUID(session.company_id), { "company_strategy": strategy }) return RedirectResponse(url="/company/strategy?success=true", status_code=302) except Exception as e: logger.error(f"Error saving company strategy: {e}") company = await db.get_company(UUID(session.company_id)) return templates.TemplateResponse("company_strategy.html", { "request": request, "page": "strategy", "session": session, "company": company, "strategy": strategy, "error": str(e) }) @user_router.get("/company/accounts", response_class=HTMLResponse) async def company_accounts_page(request: Request): """Company employee management page.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Only company owners can access if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) company_id = UUID(session.company_id) employees = await db.get_company_employees(company_id) pending_invitations = await db.get_pending_invitations(company_id) user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_accounts.html", { "request": request, "page": "accounts", "session": session, "employees": employees, "pending_invitations": pending_invitations, "profile_picture": profile_picture }) # ==================== COMPANY MANAGE ROUTES ==================== @user_router.get("/company/manage", response_class=HTMLResponse) async def company_manage_page(request: Request, employee_id: str = None): """Company content management page - manage employee posts and research.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Only company owners can access if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) company_id = UUID(session.company_id) # Get all employees with completed onboarding all_employees = await db.get_company_employees(company_id) active_employees = [emp for emp in all_employees if emp.onboarding_status == "completed"] # Build display info for employees with correct avatar URLs (parallel) # Note: emp is a User object from get_company_employees which has linkedin_name and linkedin_picture def _make_emp_session_manage(emp): return UserSession( user_id=str(emp.id), linkedin_picture=emp.linkedin_picture, email=emp.email, account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type, display_name=emp.display_name ) emp_sessions_manage = [_make_emp_session_manage(emp) for emp in active_employees] emp_avatars = await asyncio.gather(*[get_user_avatar(s, emp.id) for s, emp in zip(emp_sessions_manage, active_employees)]) active_employees_info = [ { "id": str(emp.id), "email": emp.email, "display_name": emp.linkedin_name or emp.display_name or emp.email, "linkedin_picture": avatar_url, "onboarding_status": emp.onboarding_status } for emp, avatar_url in zip(active_employees, emp_avatars) ] # Selected employee data selected_employee = None employee_posts = [] pending_posts = 0 approved_posts = 0 selected_permissions = None if employee_id: # Find the selected employee for emp in active_employees_info: if emp["id"] == employee_id: selected_employee = emp break if selected_employee: emp_profile, employee_posts = await asyncio.gather( db.get_profile(UUID(employee_id)), db.get_generated_posts(UUID(employee_id)), ) if emp_profile: pending_posts = len([p for p in employee_posts if p.status in ['draft', 'pending']]) approved_posts = len([p for p in employee_posts if p.status in ['approved', 'published']]) selected_permissions = await get_employee_permissions_or_default(emp_profile.id, company_id) user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_manage.html", { "request": request, "page": "manage", "session": session, "active_employees": active_employees_info, "selected_employee": selected_employee, "employee_posts": employee_posts, "pending_posts": pending_posts, "approved_posts": approved_posts, "selected_permissions": selected_permissions, "profile_picture": profile_picture }) @user_router.get("/company/manage/posts", response_class=HTMLResponse) async def company_manage_posts(request: Request, employee_id: str = None): """View all posts for a specific employee.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) # Get employee info emp_profile = await db.get_profile(UUID(employee_id)) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) # Verify employee belongs to this company emp_user = await db.get_user(UUID(employee_id)) if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) profile = await db.get_profile(emp_profile.id) posts = await db.get_generated_posts(emp_profile.id) user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) permissions = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) # Enforce can_view_posts permission if not permissions.get("can_view_posts", True): return RedirectResponse(url=f"/company/manage?employee_id={employee_id}&error=no_view_permission", status_code=302) return templates.TemplateResponse("company_manage_posts.html", { "request": request, "page": "manage", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "profile": profile, "posts": posts, "total_posts": len(posts), "profile_picture": profile_picture, "permissions": permissions, "current_employee_id": employee_id, }) @user_router.get("/company/manage/post/{post_id}", response_class=HTMLResponse) async def company_manage_post_detail(request: Request, post_id: str, employee_id: str = None): """View a specific post for an employee.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) # Get employee info + post in parallel emp_profile, emp_user, post = await asyncio.gather( db.get_profile(UUID(employee_id)), db.get_user(UUID(employee_id)), db.get_generated_post(UUID(post_id)), ) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) # Verify employee belongs to this company if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) if not post or str(post.user_id) != str(emp_profile.id): return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302) profile = emp_profile # same object - no second DB call needed # Get employee's avatar emp_session = UserSession( user_id=str(emp_profile.id), linkedin_picture=emp_user.linkedin_picture, email=emp_user.email, account_type=emp_user.account_type.value if hasattr(emp_user.account_type, 'value') else emp_user.account_type, display_name=emp_profile.display_name ) profile_picture_url = await get_user_avatar(emp_session, emp_profile.id) # Convert media_items to dicts for JSON serialization in template media_items_dict = [] if post.media_items: media_items_dict = [ item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item) for item in post.media_items ] permissions = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) # Check token limit for AI quick actions limit_reached = False limit_message = "" if session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_proceed limit_message = error_msg return templates.TemplateResponse("company_manage_post_detail.html", { "request": request, "page": "manage", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "profile": profile, "post": post, "media_items_dict": media_items_dict, "profile_picture_url": profile_picture_url, "permissions": permissions, "current_employee_id": employee_id, "limit_reached": limit_reached, "limit_message": limit_message, }) @user_router.get("/company/manage/research", response_class=HTMLResponse) async def company_manage_research(request: Request, employee_id: str = None): """Research page for a specific employee.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) # Get employee info emp_profile = await db.get_profile(UUID(employee_id)) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) # Verify employee belongs to this company emp_user = await db.get_user(UUID(employee_id)) if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) # Check token limit limit_reached = False limit_message = "" if session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_manage_research.html", { "request": request, "page": "manage", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "user_id": str(emp_profile.id), "limit_reached": limit_reached, "limit_message": limit_message, "profile_picture": profile_picture }) @user_router.get("/company/manage/create", response_class=HTMLResponse) async def company_manage_create(request: Request, employee_id: str = None): """Create post page for a specific employee.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) # Get employee info emp_profile = await db.get_profile(UUID(employee_id)) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) # Verify employee belongs to this company emp_user = await db.get_user(UUID(employee_id)) if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) # Check token limit limit_reached = False limit_message = "" if session.company_id: can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("company_manage_create.html", { "request": request, "page": "manage", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "user_id": str(emp_profile.id), "limit_reached": limit_reached, "limit_message": limit_message, "profile_picture": profile_picture }) # ==================== COMPANY MANAGE CHAT-CREATE & POST TYPES ==================== @user_router.get("/company/manage/chat-create", response_class=HTMLResponse) async def company_manage_chat_create(request: Request, employee_id: str = None): """Chat-create page for a specific employee in company context.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) emp_profile = await db.get_profile(UUID(employee_id)) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) emp_user = await db.get_user(UUID(employee_id)) if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) # Check permission permissions = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) if not permissions.get("can_create_posts", True): return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302) # Check token limit limit_reached = False limit_message = "" can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) limit_reached = not can_create limit_message = error_msg # Get employee's post types emp_post_types = await db.get_post_types(emp_profile.id) if not emp_post_types: emp_post_types = [] user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) # Load employee's saved posts for sidebar all_posts = await db.get_generated_posts(emp_profile.id) saved_posts = [post for post in all_posts if post.status not in ['scheduled', 'published']] return templates.TemplateResponse("company_manage_chat_create.html", { "request": request, "page": "manage_chat_create", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "post_types": emp_post_types, "profile_picture": profile_picture, "saved_posts": saved_posts, "limit_reached": limit_reached, "limit_message": limit_message, "permissions": permissions, "current_employee_id": employee_id, }) @user_router.get("/company/manage/post-types", response_class=HTMLResponse) async def company_manage_post_types(request: Request, employee_id: str = None): """Post types management page for a specific employee in company context.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) if not employee_id: return RedirectResponse(url="/company/manage", status_code=302) emp_profile = await db.get_profile(UUID(employee_id)) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) emp_user = await db.get_user(UUID(employee_id)) if not emp_user or str(emp_user.company_id) != session.company_id: return RedirectResponse(url="/company/manage", status_code=302) # Check permission permissions = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) if not permissions.get("can_manage_post_types", True): return RedirectResponse(url=f"/company/manage?employee_id={employee_id}", status_code=302) user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) try: import json emp_id = emp_profile.id post_types = await db.get_post_types(emp_id, active_only=True) post_types_with_counts = [] for pt in post_types: posts = await db.get_posts_by_type(emp_id, pt.id) post_types_with_counts.append({ "post_type": { "id": str(pt.id), "name": pt.name, "description": pt.description, "strategy_weight": pt.strategy_weight, "is_active": pt.is_active }, "post_count": len(posts) }) post_types_json = json.dumps(post_types_with_counts) except Exception as e: logger.error(f"Error loading company post types: {e}") import json post_types_with_counts = [] post_types_json = json.dumps([]) return templates.TemplateResponse("company_manage_post_types.html", { "request": request, "page": "manage_post_types", "session": session, "employee_id": employee_id, "employee_name": emp_user.linkedin_name or emp_profile.display_name or emp_user.email, "post_types_with_counts": post_types_with_counts, "post_types_json": post_types_json, "profile_picture": profile_picture, "permissions": permissions, "current_employee_id": employee_id, }) # ==================== EMPLOYEE ROUTES ==================== @user_router.get("/employee/strategy", response_class=HTMLResponse) async def employee_strategy_page(request: Request): """Read-only company strategy view for employees.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) # Only employees can access this page if session.account_type != "employee" or not session.company_id: return RedirectResponse(url="/", status_code=302) company = await db.get_company(UUID(session.company_id)) strategy = company.company_strategy if company else {} user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) return templates.TemplateResponse("employee_strategy.html", { "request": request, "page": "strategy", "session": session, "company": company, "strategy": strategy, "profile_picture": profile_picture }) # ============================================================================ # EMPLOYEE POST TYPES MANAGEMENT # ============================================================================ @user_router.get("/post-types/manage") async def employee_post_types_page(request: Request): """Employee post types management page with strategy weight configuration.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "employee": raise HTTPException(status_code=403, detail="Only employees can access this page") try: user_id = UUID(session.user_id) # Get all active post types for this employee post_types = await db.get_post_types(user_id, active_only=True) # Count posts for each post type and convert to JSON-serializable format post_types_with_counts = [] for pt in post_types: posts = await db.get_posts_by_type(user_id, pt.id) post_types_with_counts.append({ "post_type": { "id": str(pt.id), "name": pt.name, "description": pt.description, "strategy_weight": pt.strategy_weight, "is_active": pt.is_active }, "post_count": len(posts) }) # Check if company strategy exists company_strategy = None has_strategy = False if session.company_id: company_id = UUID(session.company_id) company = await db.get_company(company_id) if company and company.company_strategy: company_strategy = company.company_strategy has_strategy = True profile_picture = await get_user_avatar(session, user_id) # Convert to JSON string for JavaScript import json post_types_json = json.dumps(post_types_with_counts) logger.info(f"Generated JSON for {len(post_types_with_counts)} post types") logger.info(f"JSON length: {len(post_types_json)} characters") logger.info(f"JSON preview: {post_types_json[:200] if len(post_types_json) > 200 else post_types_json}") return templates.TemplateResponse("employee_post_types.html", { "request": request, "page": "post_types", "session": session, "post_types_with_counts": post_types_with_counts, "post_types_json": post_types_json, "has_strategy": has_strategy, "company_strategy": company_strategy, "profile_picture": profile_picture }) except Exception as e: logger.error(f"Error loading employee post types page: {e}") profile_picture = await get_user_avatar(session, UUID(session.user_id)) import json return templates.TemplateResponse("employee_post_types.html", { "request": request, "page": "post_types", "session": session, "post_types_with_counts": [], "post_types_json": json.dumps([]), "has_strategy": False, "company_strategy": None, "error": str(e), "profile_picture": profile_picture }) @user_router.post("/api/employee/post-types") async def create_employee_post_type(request: Request, background_tasks: BackgroundTasks): """Create a new post type for an employee.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "employee": return JSONResponse({"error": "Only employees can create post types"}, status_code=403) try: data = await request.json() user_id = UUID(session.user_id) # Validate required fields name_raw = data.get("name") name = name_raw.strip() if name_raw else "" if not name or len(name) < 3: return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) description_raw = data.get("description") description = description_raw.strip() if description_raw else "" strategy_weight = float(data.get("strategy_weight", 0.5)) # Validate strategy_weight range if not (0.0 <= strategy_weight <= 1.0): return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) # Check if an inactive post type with this name already exists existing_inactive = None try: all_post_types = await db.get_post_types(user_id, active_only=False) existing_inactive = next((pt for pt in all_post_types if pt.name.lower() == name.lower() and not pt.is_active), None) except Exception as check_error: logger.warning(f"Could not check for inactive post types: {check_error}") if existing_inactive: # Reactivate the existing post type instead of creating a new one await db.update_post_type(existing_inactive.id, { "is_active": True, "description": description if description else existing_inactive.description, "strategy_weight": strategy_weight }) created_post_type = await db.get_post_type(existing_inactive.id) logger.info(f"Reactivated post type '{name}' for user {user_id}") else: # Create new post type from src.database.models import PostType post_type = PostType( user_id=user_id, name=name, description=description if description else None, strategy_weight=strategy_weight, is_active=True ) created_post_type = await db.create_post_type(post_type) logger.info(f"Created post type '{name}' for user {user_id}") return JSONResponse({ "success": True, "post_type": { "id": str(created_post_type.id), "name": created_post_type.name, "description": created_post_type.description, "strategy_weight": created_post_type.strategy_weight } }) except Exception as e: logger.error(f"Error creating post type: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.put("/api/employee/post-types/{post_type_id}") async def update_employee_post_type(request: Request, post_type_id: str): """Update an existing post type.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "employee": return JSONResponse({"error": "Only employees can update post types"}, status_code=403) try: user_id = UUID(session.user_id) pt_id = UUID(post_type_id) # Check ownership post_type = await db.get_post_type(pt_id) if not post_type or post_type.user_id != user_id: return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) data = await request.json() updates = {} # Update name if provided if "name" in data: name_raw = data["name"] name = name_raw.strip() if name_raw else "" if not name or len(name) < 3: return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) updates["name"] = name # Update description if provided if "description" in data: desc_raw = data["description"] updates["description"] = desc_raw.strip() if desc_raw else None # Update strategy_weight if provided if "strategy_weight" in data: strategy_weight = float(data["strategy_weight"]) if not (0.0 <= strategy_weight <= 1.0): return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) updates["strategy_weight"] = strategy_weight # Apply updates if updates: await db.update_post_type(pt_id, updates) return JSONResponse({"success": True}) except Exception as e: logger.error(f"Error updating post type: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.delete("/api/employee/post-types/{post_type_id}") async def delete_employee_post_type(request: Request, post_type_id: str, background_tasks: BackgroundTasks): """Soft delete a post type (set is_active = False).""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "employee": return JSONResponse({"error": "Only employees can delete post types"}, status_code=403) try: user_id = UUID(session.user_id) pt_id = UUID(post_type_id) # Check ownership post_type = await db.get_post_type(pt_id) if not post_type or post_type.user_id != user_id: return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) # Count affected posts posts = await db.get_posts_by_type(user_id, pt_id) affected_count = len(posts) # Soft delete await db.update_post_type(pt_id, {"is_active": False}) logger.info(f"Deleted post type '{post_type.name}' for user {user_id}") return JSONResponse({ "success": True, "affected_posts": affected_count }) except Exception as e: logger.error(f"Error deleting post type: {e}") return JSONResponse({"error": str(e)}, status_code=500) # ============================================================================ # COMPANY MANAGING EMPLOYEE POST TYPES # ============================================================================ @user_router.post("/api/company/manage/post-types") async def company_create_employee_post_type(request: Request, background_tasks: BackgroundTasks): """Company creates a post type for an employee.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "company" or not session.company_id: return JSONResponse({"error": "Only company accounts can use this endpoint"}, status_code=403) try: data = await request.json() employee_id_str = data.get("employee_id") if not employee_id_str: return JSONResponse({"error": "employee_id required"}, status_code=400) emp_id = UUID(employee_id_str) # Verify employee belongs to company emp_user = await db.get_user(emp_id) if not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"error": "Employee not found or not in company"}, status_code=403) perms = await get_employee_permissions_or_default(emp_id, UUID(session.company_id)) if not perms.get("can_manage_post_types", True): return JSONResponse({"error": "Keine Berechtigung zum Verwalten von Post-Typen"}, status_code=403) name_raw = data.get("name") name = name_raw.strip() if name_raw else "" if not name or len(name) < 3: return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) description_raw = data.get("description") description = description_raw.strip() if description_raw else "" strategy_weight = float(data.get("strategy_weight", 0.5)) if not (0.0 <= strategy_weight <= 1.0): return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) # Check for inactive post type with same name all_post_types = await db.get_post_types(emp_id, active_only=False) existing_inactive = next((pt for pt in all_post_types if pt.name.lower() == name.lower() and not pt.is_active), None) if existing_inactive: await db.update_post_type(existing_inactive.id, { "is_active": True, "description": description if description else existing_inactive.description, "strategy_weight": strategy_weight }) created_post_type = await db.get_post_type(existing_inactive.id) else: from src.database.models import PostType post_type = PostType( user_id=emp_id, name=name, description=description if description else None, strategy_weight=strategy_weight, is_active=True ) created_post_type = await db.create_post_type(post_type) return JSONResponse({ "success": True, "post_type": { "id": str(created_post_type.id), "name": created_post_type.name, "description": created_post_type.description, "strategy_weight": created_post_type.strategy_weight } }) except Exception as e: logger.error(f"Error creating employee post type (company): {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.put("/api/company/manage/post-types/{post_type_id}") async def company_update_employee_post_type(request: Request, post_type_id: str): """Company updates a post type for an employee.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "company" or not session.company_id: return JSONResponse({"error": "Only company accounts can use this endpoint"}, status_code=403) try: data = await request.json() employee_id_str = data.get("employee_id") if not employee_id_str: return JSONResponse({"error": "employee_id required"}, status_code=400) emp_id = UUID(employee_id_str) emp_user = await db.get_user(emp_id) if not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"error": "Employee not found or not in company"}, status_code=403) perms = await get_employee_permissions_or_default(emp_id, UUID(session.company_id)) if not perms.get("can_manage_post_types", True): return JSONResponse({"error": "Keine Berechtigung"}, status_code=403) pt_id = UUID(post_type_id) post_type = await db.get_post_type(pt_id) if not post_type or post_type.user_id != emp_id: return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) updates = {} if "name" in data: name = data["name"].strip() if data["name"] else "" if not name or len(name) < 3: return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) updates["name"] = name if "description" in data: desc = data["description"] updates["description"] = desc.strip() if desc else None if "strategy_weight" in data: sw = float(data["strategy_weight"]) if not (0.0 <= sw <= 1.0): return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) updates["strategy_weight"] = sw if updates: await db.update_post_type(pt_id, updates) return JSONResponse({"success": True}) except Exception as e: logger.error(f"Error updating employee post type (company): {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.delete("/api/company/manage/post-types/{post_type_id}") async def company_delete_employee_post_type(request: Request, post_type_id: str, employee_id: str = None): """Company soft-deletes a post type for an employee.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "company" or not session.company_id: return JSONResponse({"error": "Only company accounts can use this endpoint"}, status_code=403) if not employee_id: return JSONResponse({"error": "employee_id required"}, status_code=400) try: emp_id = UUID(employee_id) emp_user = await db.get_user(emp_id) if not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"error": "Employee not found or not in company"}, status_code=403) perms = await get_employee_permissions_or_default(emp_id, UUID(session.company_id)) if not perms.get("can_manage_post_types", True): return JSONResponse({"error": "Keine Berechtigung"}, status_code=403) pt_id = UUID(post_type_id) post_type = await db.get_post_type(pt_id) if not post_type or post_type.user_id != emp_id: return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) posts = await db.get_posts_by_type(emp_id, pt_id) await db.update_post_type(pt_id, {"is_active": False}) return JSONResponse({"success": True, "affected_posts": len(posts)}) except Exception as e: logger.error(f"Error deleting employee post type (company): {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.post("/api/company/manage/post-types/save-all") async def company_save_all_employee_post_types(request: Request, background_tasks: BackgroundTasks): """Company triggers re-categorization for employee post types.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "company" or not session.company_id: return JSONResponse({"error": "Only company accounts can use this endpoint"}, status_code=403) try: data = await request.json() employee_id_str = data.get("employee_id") has_structural_changes = data.get("has_structural_changes", False) if not employee_id_str: return JSONResponse({"error": "employee_id required"}, status_code=400) emp_id = UUID(employee_id_str) emp_user = await db.get_user(emp_id) if not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"error": "Employee not found or not in company"}, status_code=403) if has_structural_changes: categorization_job = await job_manager.create_job( job_type=JobType.POST_CATEGORIZATION, user_id=str(emp_id) ) analysis_job = await job_manager.create_job( job_type=JobType.POST_TYPE_ANALYSIS, user_id=str(emp_id) ) background_tasks.add_task(run_post_recategorization, emp_id, categorization_job.id) background_tasks.add_task(run_post_type_analysis, emp_id, analysis_job.id) return JSONResponse({"success": True, "recategorized": True}) return JSONResponse({"success": True, "recategorized": False}) except Exception as e: logger.error(f"Error in company save-all post types: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.post("/api/employee/post-types/save-all") async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks): """Save all changes and conditionally trigger re-categorization based on structural changes.""" session = require_user_session(request) if not session: return JSONResponse({"error": "Not authenticated"}, status_code=401) if session.account_type != "employee": return JSONResponse({"error": "Only employees can trigger re-analysis"}, status_code=403) try: data = await request.json() has_structural_changes = data.get("has_structural_changes", False) user_id = UUID(session.user_id) user_id_str = str(user_id) # Only trigger re-categorization and analysis if there were structural changes if has_structural_changes: # Create background job for post re-categorization (ALL posts) categorization_job = await job_manager.create_job( job_type=JobType.POST_CATEGORIZATION, user_id=user_id_str ) # Create background job for post type analysis analysis_job = await job_manager.create_job( job_type=JobType.POST_TYPE_ANALYSIS, user_id=user_id_str ) # Start background tasks background_tasks.add_task(run_post_recategorization, user_id, categorization_job.id) background_tasks.add_task(run_post_type_analysis, user_id, analysis_job.id) logger.info(f"Started re-analysis jobs for user {user_id}: categorization={categorization_job.id}, analysis={analysis_job.id}") return JSONResponse({ "success": True, "recategorized": True, "categorization_job_id": str(categorization_job.id), "analysis_job_id": str(analysis_job.id) }) else: logger.info(f"No structural changes for user {user_id}, skipping re-analysis") return JSONResponse({ "success": True, "recategorized": False, "message": "Nur Weight-Updates, keine Rekategorisierung notwendig" }) except Exception as e: logger.error(f"Error in save-all: {e}") return JSONResponse({"error": str(e)}, status_code=500) @user_router.post("/api/employee/chat/generate") async def chat_generate_post(request: Request): """Generate initial post from chat message.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() message = data.get("message", "").strip() post_type_id = data.get("post_type_id") if not message: return JSONResponse({"success": False, "error": "Nachricht erforderlich"}) if not post_type_id: return JSONResponse({"success": False, "error": "Post-Typ erforderlich"}) # Check token limit for companies/employees if session.account_type in ("company", "employee") and session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_proceed: return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg}) user_id = UUID(session.user_id) # Get post type info post_type = await db.get_post_type(UUID(post_type_id)) if not post_type: return JSONResponse({"success": False, "error": "Post-Typ nicht gefunden"}) # Get profile analysis profile_analysis = await db.get_profile_analysis(user_id) if not profile_analysis: return JSONResponse({"success": False, "error": "Profil-Analyse nicht gefunden"}) # Get company strategy if available company_strategy = None profile = await db.get_profile(user_id) if profile and profile.company_id: company = await db.get_company(profile.company_id) if company and company.company_strategy: company_strategy = company.company_strategy # Get example posts for style reference linkedin_posts = await db.get_posts_by_type(user_id, UUID(post_type_id)) if len(linkedin_posts) < 3: linkedin_posts = await db.get_linkedin_posts(user_id) example_post_texts = [ post.post_text for post in linkedin_posts if post.post_text and len(post.post_text) > 100 ][:10] # Generate post using writer agent with user's content as primary focus from src.agents.writer import WriterAgent writer = WriterAgent() writer.set_tracking_context( operation='post_creation', user_id=session.user_id, company_id=session.company_id ) # Create a topic structure from user's message topic = { "title": message[:100], "fact": message, "relevance": "User-specified content" } # Generate post post_content = await writer.process( topic=topic, profile_analysis=profile_analysis.full_analysis, example_posts=example_post_texts, post_type=post_type, user_thoughts=message, # CRITICAL: User's input as primary content company_strategy=company_strategy, strategy_weight=post_type.strategy_weight ) # Generate conversation ID import uuid conversation_id = str(uuid.uuid4()) return JSONResponse({ "success": True, "post": post_content, "conversation_id": conversation_id, "explanation": "Hier ist dein erster Entwurf basierend auf deiner Beschreibung:" }) except Exception as e: logger.error(f"Error generating chat post: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.post("/api/employee/chat/refine") async def chat_refine_post(request: Request): """Refine existing post based on user feedback.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() message = data.get("message", "").strip() current_post = data.get("current_post", "") post_type_id = data.get("post_type_id") chat_history = data.get("chat_history", []) if not message: return JSONResponse({"success": False, "error": "Nachricht erforderlich"}) if not current_post: return JSONResponse({"success": False, "error": "Kein Post zum Verfeinern vorhanden"}) if not post_type_id: return JSONResponse({"success": False, "error": "Post-Typ erforderlich"}) # Check token limit for companies/employees if session.account_type in ("company", "employee") and session.company_id: can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_proceed: return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg}) user_id = UUID(session.user_id) # Get post type info post_type = await db.get_post_type(UUID(post_type_id)) if not post_type: return JSONResponse({"success": False, "error": "Post-Typ nicht gefunden"}) # Get profile analysis profile_analysis = await db.get_profile_analysis(user_id) if not profile_analysis: return JSONResponse({"success": False, "error": "Profil-Analyse nicht gefunden"}) # Ensure full_analysis is a dict full_analysis = profile_analysis.full_analysis if profile_analysis.full_analysis else {} if not isinstance(full_analysis, dict): logger.warning(f"full_analysis is not a dict: {type(full_analysis)}") full_analysis = {} # Get company strategy if available company_strategy = None profile = await db.get_profile(user_id) if profile and profile.company_id: company = await db.get_company(profile.company_id) if company and company.company_strategy: company_strategy = company.company_strategy # Ensure it's a dict if not isinstance(company_strategy, dict): logger.warning(f"company_strategy is not a dict: {type(company_strategy)}") company_strategy = None # Get example posts linkedin_posts = await db.get_posts_by_type(user_id, UUID(post_type_id)) if len(linkedin_posts) < 3: linkedin_posts = await db.get_linkedin_posts(user_id) example_post_texts = [ post.post_text for post in linkedin_posts if post.post_text and len(post.post_text) > 100 ][:10] # Refine post using writer with feedback from src.agents.writer import WriterAgent writer = WriterAgent() writer.set_tracking_context( operation='post_creation', user_id=session.user_id, company_id=session.company_id ) topic = { "title": "Chat refinement", "fact": message, "relevance": "User refinement request" } # Use writer's revision capability refined_post = await writer.process( topic=topic, profile_analysis=full_analysis, example_posts=example_post_texts, feedback=message, # User's refinement instruction previous_version=current_post, post_type=post_type, user_thoughts=message, company_strategy=company_strategy, strategy_weight=getattr(post_type, 'strategy_weight', 0.5) ) return JSONResponse({ "success": True, "post": refined_post, "conversation_id": data.get("conversation_id"), "explanation": "Ich habe den Post angepasst:" }) except Exception as e: import traceback logger.error(f"Error refining chat post: {e}") logger.error(f"Traceback: {traceback.format_exc()}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.post("/api/employee/chat/save") async def chat_save_post(request: Request): """Save chat-generated post to database.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() post_content = data.get("post_content", "").strip() post_type_id = data.get("post_type_id") chat_history = data.get("chat_history", []) if not post_content: return JSONResponse({"success": False, "error": "Post-Inhalt erforderlich"}) if not post_type_id: return JSONResponse({"success": False, "error": "Post-Typ erforderlich"}) user_id = UUID(session.user_id) # Extract title from first sentence of post first_sentence = post_content.split('\n')[0].strip() if len(first_sentence) > 100: title = first_sentence[:97] + "..." else: title = first_sentence if first_sentence else "Chat-generierter Post" # Create GeneratedPost with status draft from src.database.models import GeneratedPost import uuid as uuid_lib post_id = uuid_lib.uuid4() # Extract all AI-generated versions from chat history writer_versions = [] critic_feedback_list = [] for item in chat_history: if 'ai' in item and item['ai']: writer_versions.append(item['ai']) # Store user feedback as "critic feedback" if 'user' in item and item['user']: critic_feedback_list.append({ 'feedback': item['user'], 'explanation': item.get('explanation', '') }) # Add final version writer_versions.append(post_content) num_iterations = len(writer_versions) generated_post = GeneratedPost( id=post_id, user_id=user_id, post_content=post_content, post_type_id=UUID(post_type_id), status="draft", iterations=num_iterations, writer_versions=writer_versions, # All iterations saved here critic_feedback=critic_feedback_list, # User feedback saved here topic_title=title, created_at=datetime.now(timezone.utc) ) saved_post = await db.save_generated_post(generated_post) return JSONResponse({ "success": True, "post_id": str(saved_post.id), "message": "Post erfolgreich gespeichert" }) except Exception as e: logger.error(f"Error saving chat post: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.get("/api/employee/chat/history/{post_id}") async def get_chat_history(request: Request, post_id: str): """Get chat history for a saved post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: user_id = UUID(session.user_id) post_uuid = UUID(post_id) # Fetch post post = await db.get_generated_post(post_uuid) if not post: return JSONResponse({"success": False, "error": "Post nicht gefunden"}, status_code=404) # Verify ownership if post.user_id != user_id: return JSONResponse({"success": False, "error": "Nicht autorisiert"}, status_code=403) # Reconstruct chat history from writer_versions and critic_feedback chat_history = [] # First version: AI generates initial post (no user message) if post.writer_versions and len(post.writer_versions) > 0: first_explanation = "Hier ist dein erster Entwurf:" # Check if first critic feedback has explanation (from AI) if post.critic_feedback and len(post.critic_feedback) > 0: first_explanation = post.critic_feedback[0].get('explanation', first_explanation) chat_history.append({ "user": "", # No user message for first generation "ai": post.writer_versions[0], "explanation": first_explanation }) # Subsequent versions: User feedback → AI refined version for i in range(1, len(post.writer_versions)): user_message = "" explanation = "Hier ist die überarbeitete Version:" # Get user feedback from critic_feedback (offset by 1, since first is for initial) if i <= len(post.critic_feedback): feedback_item = post.critic_feedback[i - 1] user_message = feedback_item.get('feedback', '') explanation = feedback_item.get('explanation', explanation) chat_history.append({ "user": user_message, "ai": post.writer_versions[i], "explanation": explanation }) return JSONResponse({ "success": True, "chat_history": chat_history, "post": post.post_content, "post_type_id": str(post.post_type_id) if post.post_type_id else None, "topic_title": post.topic_title }) except ValueError: return JSONResponse({"success": False, "error": "Ungültige Post-ID"}, status_code=400) except Exception as e: logger.error(f"Error fetching chat history: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.put("/api/employee/chat/update/{post_id}") async def update_chat_post(request: Request, post_id: str): """Update an existing post with new chat conversation.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: user_id = UUID(session.user_id) post_uuid = UUID(post_id) data = await request.json() post_content = data.get("post_content", "").strip() chat_history = data.get("chat_history", []) if not post_content: return JSONResponse({"success": False, "error": "Post-Inhalt erforderlich"}) # Fetch existing post post = await db.get_generated_post(post_uuid) if not post: return JSONResponse({"success": False, "error": "Post nicht gefunden"}, status_code=404) # Verify ownership if post.user_id != user_id: return JSONResponse({"success": False, "error": "Nicht autorisiert"}, status_code=403) # Extract all AI-generated versions from chat history writer_versions = [] critic_feedback_list = [] for item in chat_history: if 'ai' in item and item['ai']: writer_versions.append(item['ai']) # Store user feedback as "critic feedback" if 'user' in item and item['user']: critic_feedback_list.append({ 'feedback': item['user'], 'explanation': item.get('explanation', '') }) # Prepare update data updates = { 'post_content': post_content, 'writer_versions': writer_versions, 'critic_feedback': critic_feedback_list, 'iterations': len(writer_versions) } # Update the post using the correct method updated_post = await db.update_generated_post(post_uuid, updates) return JSONResponse({ "success": True, "post_id": str(updated_post.id), "message": "Post erfolgreich aktualisiert" }) except ValueError: return JSONResponse({"success": False, "error": "Ungültige Post-ID"}, status_code=400) except Exception as e: logger.error(f"Error updating chat post: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) # ==================== COMPANY MANAGE CHAT API (PROXY FOR EMPLOYEE) ==================== @user_router.post("/api/company/manage/chat/generate") async def company_chat_generate_post(request: Request): """Generate a post for an employee via company context.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "company" or not session.company_id: raise HTTPException(status_code=403, detail="Company account required") try: data = await request.json() employee_id = data.get("employee_id") message = data.get("message", "").strip() post_type_id = data.get("post_type_id") if not employee_id or not message or not post_type_id: return JSONResponse({"success": False, "error": "employee_id, message und post_type_id erforderlich"}) # Verify employee belongs to this company emp_profile = await db.get_profile(UUID(employee_id)) emp_user = await db.get_user(UUID(employee_id)) if not emp_profile or not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"success": False, "error": "Mitarbeiter nicht gefunden"}, status_code=404) # Check permission perms = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) if not perms.get("can_create_posts", True): return JSONResponse({"success": False, "error": "Keine Berechtigung"}, status_code=403) # Check token limit can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) if not can_proceed: return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg}) user_id = emp_profile.id post_type = await db.get_post_type(UUID(post_type_id)) if not post_type: return JSONResponse({"success": False, "error": "Post-Typ nicht gefunden"}) profile_analysis = await db.get_profile_analysis(user_id) if not profile_analysis: return JSONResponse({"success": False, "error": "Profil-Analyse nicht gefunden. Bitte führe erst eine Profilanalyse durch."}) company = await db.get_company(UUID(session.company_id)) company_strategy = company.company_strategy if company else None linkedin_posts = await db.get_posts_by_type(user_id, UUID(post_type_id)) if len(linkedin_posts) < 3: linkedin_posts = await db.get_linkedin_posts(user_id) example_post_texts = [ post.post_text for post in linkedin_posts if post.post_text and len(post.post_text) > 100 ][:10] from src.agents.writer import WriterAgent import uuid writer = WriterAgent() writer.set_tracking_context( operation='post_creation', user_id=session.user_id, company_id=session.company_id ) topic = {"title": message[:100], "fact": message, "relevance": "Company-created content"} post_content = await writer.process( topic=topic, profile_analysis=profile_analysis.full_analysis, example_posts=example_post_texts, post_type=post_type, user_thoughts=message, company_strategy=company_strategy, strategy_weight=post_type.strategy_weight ) return JSONResponse({ "success": True, "post": post_content, "conversation_id": str(uuid.uuid4()), "explanation": "Hier ist der erste Entwurf basierend auf der Beschreibung:" }) except Exception as e: logger.error(f"Error generating company chat post: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.post("/api/company/manage/chat/save") async def company_chat_save_post(request: Request): """Save a chat-generated post for an employee.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "company" or not session.company_id: raise HTTPException(status_code=403, detail="Company account required") try: data = await request.json() employee_id = data.get("employee_id") post_content = data.get("post_content", "").strip() post_type_id = data.get("post_type_id") chat_history = data.get("chat_history", []) topic_title = data.get("topic_title", post_content[:80] if post_content else "Chat Post") if not employee_id or not post_content: return JSONResponse({"success": False, "error": "employee_id und post_content erforderlich"}) emp_profile = await db.get_profile(UUID(employee_id)) emp_user = await db.get_user(UUID(employee_id)) if not emp_profile or not emp_user or str(emp_user.company_id) != session.company_id: return JSONResponse({"success": False, "error": "Mitarbeiter nicht gefunden"}, status_code=404) perms = await get_employee_permissions_or_default(emp_profile.id, UUID(session.company_id)) if not perms.get("can_create_posts", True): return JSONResponse({"success": False, "error": "Keine Berechtigung"}, status_code=403) writer_versions = [item['ai'] for item in chat_history if 'ai' in item and item['ai']] critic_feedback_list = [] for item in chat_history: if 'user' in item and item['user']: critic_feedback_list.append({'feedback': item['user'], 'explanation': item.get('explanation', '')}) from src.database.models import GeneratedPost as GenPost new_post = GenPost( user_id=emp_profile.id, topic_title=topic_title, post_content=post_content, writer_versions=writer_versions if writer_versions else [post_content], critic_feedback=critic_feedback_list, iterations=len(writer_versions) if writer_versions else 1, status="draft", post_type_id=UUID(post_type_id) if post_type_id else None ) saved_post = await db.save_generated_post(new_post) return JSONResponse({ "success": True, "post_id": str(saved_post.id), "message": "Post erfolgreich gespeichert" }) except Exception as e: logger.error(f"Error saving company chat post: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) @user_router.post("/api/company/invite") async def send_company_invitation(request: Request): """Send invitation to a new employee.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "company" or not session.company_id: raise HTTPException(status_code=403, detail="Not a company owner") try: # CHECK EMPLOYEE LIMIT company_id = UUID(session.company_id) can_add, error_msg = await db.check_company_employee_limit(company_id) if not can_add: return {"success": False, "error": error_msg} data = await request.json() email = data.get("email", "").lower().strip() if not email: return {"success": False, "error": "E-Mail erforderlich"} # Check if user already exists in the system existing_user = await db.get_user_by_email(email) if existing_user: # User exists - check their status if existing_user.company_id and str(existing_user.company_id) == session.company_id: return {"success": False, "error": "Benutzer ist bereits Mitarbeiter in deinem Unternehmen"} elif existing_user.company_id: return {"success": False, "error": "Diese E-Mail ist bereits bei einem anderen Unternehmen registriert"} else: return {"success": False, "error": "Diese E-Mail ist bereits als Ghostwriter-Account registriert"} # Check if email already has pending invitation existing = await db.get_invitations_by_email(email) for inv in existing: inv_status = inv.status.value if hasattr(inv.status, 'value') else inv.status if inv_status == "pending" and str(inv.company_id) == session.company_id: return {"success": False, "error": "Einladung bereits gesendet"} # Create invitation from src.database.models import Invitation invitation = Invitation( email=email, token=generate_invitation_token(), expires_at=get_invitation_expiry(), company_id=UUID(session.company_id), invited_by_user_id=UUID(session.user_id) ) created_invitation = await db.create_invitation(invitation) # Send invitation email from src.services.email_service import send_invitation_email base_url = str(request.base_url).rstrip('/') email_sent = send_invitation_email( to_email=email, company_name=session.company_name or "Unternehmen", inviter_name=session.linkedin_name or session.email, token=created_invitation.token, base_url=base_url ) return {"success": True, "email_sent": email_sent} except Exception as e: logger.exception(f"Failed to send invitation: {e}") return {"success": False, "error": str(e)} @user_router.delete("/api/company/invitations/{invitation_id}") async def cancel_invitation(request: Request, invitation_id: str): """Cancel a pending invitation.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "company" or not session.company_id: raise HTTPException(status_code=403, detail="Not a company owner") try: invitation = await db.get_invitation(UUID(invitation_id)) if not invitation or str(invitation.company_id) != session.company_id: return {"success": False, "error": "Einladung nicht gefunden"} await db.update_invitation(UUID(invitation_id), {"status": "cancelled"}) return {"success": True} except Exception as e: logger.exception(f"Failed to cancel invitation: {e}") return {"success": False, "error": str(e)} @user_router.delete("/api/company/employees/{user_id}") async def remove_employee(request: Request, user_id: str): """Remove an employee from the company - deletes user completely.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if session.account_type != "company" or not session.company_id: raise HTTPException(status_code=403, detail="Not a company owner") try: user = await db.get_user(UUID(user_id)) if not user or str(user.company_id) != session.company_id: return {"success": False, "error": "Mitarbeiter nicht gefunden"} # Store user info before deletion for email employee_email = user.email employee_name = user.display_name or user.linkedin_name or user.email company_name = session.company_name or "Unternehmen" # Completely delete user and all related data await db.delete_user_completely(UUID(user_id)) # Send notification email from src.services.email_service import send_employee_removal_email email_sent = send_employee_removal_email( to_email=employee_email, employee_name=employee_name, company_name=company_name ) logger.info(f"Removed employee {user_id} from company {session.company_id}, email_sent={email_sent}") return {"success": True, "email_sent": email_sent} except Exception as e: logger.exception(f"Failed to remove employee: {e}") return {"success": False, "error": str(e)} # ==================== ONBOARDING API ENDPOINTS ==================== @user_router.post("/api/onboarding/scrape-posts") async def api_scrape_posts(request: Request): """Scrape posts for onboarding.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") if not session.user_id: return {"success": False, "error": "User not found"} try: data = await request.json() user_id = UUID(data.get("user_id", session.user_id)) profile = await db.get_profile(user_id) if not profile: return {"success": False, "error": "User profile not found"} from src.scraper import scraper from src.database.models import LinkedInPost raw_posts = await scraper.scrape_posts(profile.linkedin_url, limit=50) parsed_posts = scraper.parse_posts_data(raw_posts) linkedin_posts = [] for post_data in parsed_posts: post = LinkedInPost(user_id=user_id, **post_data) linkedin_posts.append(post) if linkedin_posts: await db.save_linkedin_posts(linkedin_posts) return {"success": True, "posts_count": len(linkedin_posts)} except Exception as e: logger.exception(f"Failed to scrape posts: {e}") return {"success": False, "error": str(e)} @user_router.post("/api/onboarding/add-manual-posts") async def api_add_manual_posts(request: Request): """Add manual example posts.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() user_id = UUID(data.get("user_id", session.user_id)) posts_data = data.get("posts", []) from uuid import uuid4 from datetime import datetime from src.database.models import LinkedInPost posts = [] for p in posts_data: post = LinkedInPost( user_id=user_id, post_text=p.get("post_text", ""), post_url=f"manual://{uuid4()}", post_date=datetime.now(timezone.utc), classification_method="manual" ) posts.append(post) if posts: await db.save_linkedin_posts(posts) return {"success": True, "count": len(posts)} except Exception as e: logger.exception(f"Failed to add manual posts: {e}") return {"success": False, "error": str(e)} @user_router.delete("/api/onboarding/remove-manual-post/{post_id}") async def api_remove_manual_post(request: Request, post_id: str): """Remove a manual example post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: await db.delete_linkedin_post(UUID(post_id)) return {"success": True} except Exception as e: logger.exception(f"Failed to remove manual post: {e}") return {"success": False, "error": str(e)} @user_router.post("/api/onboarding/add-reference") async def api_add_reference_profile(request: Request): """Add a reference profile for scraping.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() user_id = UUID(data.get("user_id", session.user_id)) linkedin_url = data.get("linkedin_url", "") from src.database.models import ReferenceProfile profile = ReferenceProfile( user_id=user_id, linkedin_url=linkedin_url ) await db.create_reference_profile(profile) return {"success": True} except Exception as e: logger.exception(f"Failed to add reference profile: {e}") return {"success": False, "error": str(e)} @user_router.delete("/api/onboarding/remove-reference/{profile_id}") async def api_remove_reference_profile(request: Request, profile_id: str): """Remove a reference profile.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: await db.delete_reference_profile(UUID(profile_id)) return {"success": True} except Exception as e: logger.exception(f"Failed to remove reference profile: {e}") return {"success": False, "error": str(e)} @user_router.post("/api/onboarding/classify-posts") async def api_classify_posts(request: Request): """Run automatic post classification.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() user_id = UUID(data.get("user_id", session.user_id)) classified_count = await orchestrator.classify_posts(user_id) return {"success": True, "classified_count": classified_count} except Exception as e: logger.exception(f"Failed to classify posts: {e}") return {"success": False, "error": str(e)} @user_router.post("/api/onboarding/categorize-post") async def api_categorize_single_post(request: Request): """Manually categorize a single post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: data = await request.json() post_id = UUID(data.get("post_id")) post_type_id = UUID(data.get("post_type_id")) await db.update_post_classification( post_id=post_id, post_type_id=post_type_id, classification_method="manual", classification_confidence=1.0 ) return {"success": True} except Exception as e: logger.exception(f"Failed to categorize post: {e}") return {"success": False, "error": str(e)} # ==================== CALENDAR / SCHEDULING ENDPOINTS ==================== @user_router.get("/company/calendar", response_class=HTMLResponse) async def company_calendar_page(request: Request, month: int = None, year: int = None, view: str = "month", week_start: str = None): """Company posting calendar page.""" from datetime import date, datetime, timedelta import calendar session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type != "company" or not session.company_id: return RedirectResponse(url="/", status_code=302) company_id = UUID(session.company_id) # Determine current month/year today = date.today() current_month = month or today.month current_year = year or today.year # Calculate prev/next month if current_month == 1: prev_month, prev_year = 12, current_year - 1 else: prev_month, prev_year = current_month - 1, current_year if current_month == 12: next_month, next_year = 1, current_year + 1 else: next_month, next_year = current_month + 1, current_year # Get month name in German month_names = ["Januar", "Februar", "Marz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] month_name = month_names[current_month - 1] # Get all posts for the company (already enriched with employee info - single optimized query) all_posts_raw = await db.get_scheduled_posts_for_company(company_id) # Filter out employees who have disabled calendar visibility all_posts = [] hidden_user_ids = set() can_schedule_user_ids = set() fetched_perms_uids = set() for post in all_posts_raw: uid = post.get("user_id") or post.get("employee_user_id") if uid and uid not in fetched_perms_uids and uid not in hidden_user_ids: fetched_perms_uids.add(uid) try: perms = await get_employee_permissions_or_default(UUID(uid), company_id) if not perms.get("can_see_in_calendar", True): hidden_user_ids.add(uid) continue if perms.get("can_schedule_posts", True): can_schedule_user_ids.add(uid) except Exception: can_schedule_user_ids.add(uid) # default allow if uid in hidden_user_ids: continue all_posts.append(post) # Build employee list from posts (no extra queries needed) employee_map = {} for post in all_posts: user_id = str(post.get("user_id", "")) if user_id and user_id not in employee_map: employee_map[user_id] = { "user_id": user_id, "name": post.get("employee_name", "Unbekannt"), } employee_customers = list(employee_map.values()) # Create calendar structure cal = calendar.Calendar(firstweekday=0) # Monday first month_days = cal.monthdayscalendar(current_year, current_month) # Build posts by date posts_by_date = {} unscheduled_posts = [] for idx, post in enumerate(all_posts): user_id = str(post.get("user_id", "")) employee_index = list(employee_map.keys()).index(user_id) if user_id in employee_map else 0 # Parse created_at to datetime if it's a string created_at = post.get("created_at") if isinstance(created_at, str): try: created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) except (ValueError, TypeError): created_at = None post_data = { "id": str(post.get("id")), "topic_title": post.get("topic_title", "Ohne Titel"), "post_content": post.get("post_content", ""), "status": post.get("status", "draft"), "employee_name": post.get("employee_name", "Unbekannt"), "employee_index": employee_index, "created_at": created_at } scheduled_at = post.get("scheduled_at") if scheduled_at and post.get("status") in ("scheduled", "published"): if isinstance(scheduled_at, str): scheduled_dt = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00")) else: scheduled_dt = scheduled_at date_key = scheduled_dt.strftime("%Y-%m-%d") post_data["time"] = scheduled_dt.strftime("%H:%M") if date_key not in posts_by_date: posts_by_date[date_key] = [] posts_by_date[date_key].append(post_data) elif post.get("status") == "ready": # Unscheduled but ready for scheduling uid = str(post.get("user_id", "")) post_data["can_schedule"] = uid in can_schedule_user_ids unscheduled_posts.append(post_data) # Build calendar weeks calendar_weeks = [] if view == "week": # Week view: show a single week (Mon-Sun) if week_start: try: ws_date = date.fromisoformat(week_start) except ValueError: ws_date = today - timedelta(days=today.weekday()) else: ws_date = today - timedelta(days=today.weekday()) week_data = [] for i in range(7): d = ws_date + timedelta(days=i) date_str = d.isoformat() week_data.append({ "day": d.day, "date": date_str, "other_month": d.month != current_month, "is_today": d == today, "posts": posts_by_date.get(date_str, []), "weekday_name": ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][i], "full_date": f"{d.day}. {month_names[d.month - 1]}" }) calendar_weeks.append(week_data) prev_ws = ws_date - timedelta(weeks=1) next_ws = ws_date + timedelta(weeks=1) ws_end = ws_date + timedelta(days=6) # Week label e.g. "3. - 9. Feb 2026" if ws_date.month == ws_end.month: week_label = f"{ws_date.day}. - {ws_end.day}. {month_names[ws_date.month - 1]} {ws_date.year}" else: week_label = f"{ws_date.day}. {month_names[ws_date.month - 1]} - {ws_end.day}. {month_names[ws_end.month - 1]} {ws_end.year}" else: # Month view for week in month_days: week_data = [] for day in week: if day == 0: week_data.append({ "day": "", "date": "", "other_month": True, "is_today": False, "posts": [] }) else: date_str = f"{current_year}-{current_month:02d}-{day:02d}" is_today = (day == today.day and current_month == today.month and current_year == today.year) week_data.append({ "day": day, "date": date_str, "other_month": False, "is_today": is_today, "posts": posts_by_date.get(date_str, []) }) calendar_weeks.append(week_data) week_label = None prev_ws = None next_ws = None return templates.TemplateResponse("company_calendar.html", { "request": request, "page": "calendar", "session": session, "month": current_month, "year": current_year, "month_name": month_name, "prev_month": prev_month, "prev_year": prev_year, "next_month": next_month, "next_year": next_year, "current_month": today.month, "current_year": today.year, "employees": employee_customers, "calendar_weeks": calendar_weeks, "unscheduled_posts": unscheduled_posts, "view": view, "week_label": week_label, "week_start": prev_ws.isoformat() if prev_ws else None, "prev_week_start": prev_ws.isoformat() if prev_ws else None, "next_week_start": next_ws.isoformat() if next_ws else None }) @user_router.get("/calendar", response_class=HTMLResponse) async def employee_calendar_page(request: Request, month: int = None, year: int = None, view: str = "month", week_start: str = None): """Employee/ghostwriter personal posting calendar page.""" from datetime import date, datetime, timedelta import calendar session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) if session.account_type == "company": return RedirectResponse(url="/company/calendar", status_code=302) user_id = UUID(session.user_id) today = date.today() current_month = month or today.month current_year = year or today.year if current_month == 1: prev_month, prev_year = 12, current_year - 1 else: prev_month, prev_year = current_month - 1, current_year if current_month == 12: next_month, next_year = 1, current_year + 1 else: next_month, next_year = current_month + 1, current_year month_names = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] month_name = month_names[current_month - 1] # Get all own posts that are in relevant statuses all_posts = await db.get_scheduled_posts_for_user(user_id) # Build posts by date posts_by_date = {} unscheduled_posts = [] for post in all_posts: post_data = { "id": str(post.id), "topic_title": post.topic_title or "Ohne Titel", "post_content": post.post_content, "status": post.status, } if post.scheduled_at and post.status in ("scheduled", "published"): scheduled_dt = post.scheduled_at if not scheduled_dt.tzinfo: scheduled_dt = scheduled_dt.replace(tzinfo=timezone.utc) date_key = scheduled_dt.strftime("%Y-%m-%d") post_data["time"] = scheduled_dt.strftime("%H:%M") if date_key not in posts_by_date: posts_by_date[date_key] = [] posts_by_date[date_key].append(post_data) elif post.status in ("ready", "approved"): unscheduled_posts.append(post_data) cal = calendar.Calendar(firstweekday=0) month_days = cal.monthdayscalendar(current_year, current_month) calendar_weeks = [] week_label = None prev_ws = None next_ws = None if view == "week": if week_start: try: ws_date = date.fromisoformat(week_start) except ValueError: ws_date = today - timedelta(days=today.weekday()) else: ws_date = today - timedelta(days=today.weekday()) week_data = [] for i in range(7): d = ws_date + timedelta(days=i) date_str = d.isoformat() week_data.append({ "day": d.day, "date": date_str, "other_month": d.month != current_month, "is_today": d == today, "posts": posts_by_date.get(date_str, []), "weekday_name": ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][i], "full_date": f"{d.day}. {month_names[d.month - 1]}" }) calendar_weeks.append(week_data) prev_ws = ws_date - timedelta(weeks=1) next_ws = ws_date + timedelta(weeks=1) ws_end = ws_date + timedelta(days=6) if ws_date.month == ws_end.month: week_label = f"{ws_date.day}. - {ws_end.day}. {month_names[ws_date.month - 1]} {ws_date.year}" else: week_label = f"{ws_date.day}. {month_names[ws_date.month - 1]} - {ws_end.day}. {month_names[ws_end.month - 1]} {ws_end.year}" else: for week in month_days: week_data = [] for day in week: if day == 0: week_data.append({"day": "", "date": "", "other_month": True, "is_today": False, "posts": []}) else: date_str = f"{current_year}-{current_month:02d}-{day:02d}" is_today = (day == today.day and current_month == today.month and current_year == today.year) week_data.append({ "day": day, "date": date_str, "other_month": False, "is_today": is_today, "posts": posts_by_date.get(date_str, []) }) calendar_weeks.append(week_data) avatar_url = await get_user_avatar(session, user_id) return templates.TemplateResponse("employee_calendar.html", { "request": request, "page": "calendar", "session": session, "avatar_url": avatar_url, "month": current_month, "year": current_year, "month_name": month_name, "prev_month": prev_month, "prev_year": prev_year, "next_month": next_month, "next_year": next_year, "current_month": today.month, "current_year": today.year, "calendar_weeks": calendar_weeks, "unscheduled_posts": unscheduled_posts, "view": view, "week_label": week_label, "prev_week_start": prev_ws.isoformat() if prev_ws else None, "next_week_start": next_ws.isoformat() if next_ws else None, }) @user_router.get("/api/posts/{post_id}") async def get_post_api(request: Request, post_id: str): """Get a single post as JSON.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Verify access (owner or company owner) is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") return { "id": str(post.id), "topic_title": post.topic_title, "post_content": post.post_content, "status": post.status, "scheduled_at": post.scheduled_at.isoformat() if post.scheduled_at else None, "created_at": post.created_at.isoformat() if post.created_at else None } except HTTPException: raise except Exception as e: logger.exception(f"Failed to get post: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/posts/{post_id}/schedule") async def schedule_post(request: Request, post_id: str, scheduled_at: str = Form(...)): """Schedule a post for publishing.""" from datetime import datetime session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Determine authorization is_post_owner = str(post.user_id) == session.user_id is_company_scheduling = session.account_type == "company" and session.company_id if is_post_owner: # Post owner can always schedule their own posts pass elif is_company_scheduling: # Company must have can_schedule_posts permission profile = await db.get_profile(post.user_id) if not profile or not profile.company_id or str(profile.company_id) != session.company_id: raise HTTPException(status_code=403, detail="Not authorized") perms = await get_employee_permissions_or_default(post.user_id, UUID(session.company_id)) if not perms.get("can_schedule_posts", True): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Einplanen von Posts") else: raise HTTPException(status_code=403, detail="Not authorized to schedule posts") # Only ready posts can be scheduled if post.status not in ["ready", "scheduled"]: raise HTTPException(status_code=400, detail="Only ready (approved by customer) posts can be scheduled") # Parse scheduled_at try: scheduled_datetime = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00")) except ValueError: raise HTTPException(status_code=400, detail="Invalid datetime format") # Schedule the post updated_post = await db.schedule_post( post_id=UUID(post_id), scheduled_at=scheduled_datetime, scheduled_by_user_id=UUID(session.user_id) ) return { "success": True, "scheduled_at": updated_post.scheduled_at.isoformat() if updated_post.scheduled_at else None } except HTTPException: raise except Exception as e: logger.exception(f"Failed to schedule post: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.post("/api/posts/{post_id}/unschedule") async def unschedule_post(request: Request, post_id: str): """Remove scheduling from a post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Determine authorization is_post_owner = str(post.user_id) == session.user_id is_company_scheduling = session.account_type == "company" and session.company_id if is_post_owner: # Post owner can always unschedule their own posts pass elif is_company_scheduling: profile = await db.get_profile(post.user_id) if not profile or not profile.company_id or str(profile.company_id) != session.company_id: raise HTTPException(status_code=403, detail="Not authorized") perms = await get_employee_permissions_or_default(post.user_id, UUID(session.company_id)) if not perms.get("can_schedule_posts", True): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Einplanen von Posts") else: raise HTTPException(status_code=403, detail="Not authorized to unschedule posts") # Unschedule the post updated_post = await db.unschedule_post(UUID(post_id)) return { "success": True, "status": updated_post.status } except HTTPException: raise except Exception as e: logger.exception(f"Failed to unschedule post: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== POST IMAGE UPLOAD ==================== @user_router.post("/api/posts/{post_id}/image") async def upload_post_image(request: Request, post_id: str): """DEPRECATED: Upload or replace an image for a post. Use /api/posts/{post_id}/media instead. This endpoint is kept for backward compatibility. """ # Delegate to new media upload endpoint result = await upload_post_media(request, post_id) # Return legacy format for compatibility if result.get("success") and result.get("media_item"): return {"success": True, "image_url": result["media_item"]["url"]} return result @user_router.delete("/api/posts/{post_id}/image") async def delete_post_image(request: Request, post_id: str): """Remove the image from a post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Check authorization: owner or company owner is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") # Delete from storage if post.image_url: try: await storage.delete_image(post.image_url) except Exception as e: logger.warning(f"Failed to delete image from storage: {e}") # Clear image_url on post await db.update_generated_post(UUID(post_id), {"image_url": None}) return {"success": True} except HTTPException: raise except Exception as e: logger.exception(f"Failed to delete post image: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== POST MEDIA UPLOAD (MULTI-MEDIA) ==================== @user_router.post("/api/posts/{post_id}/media") async def upload_post_media(request: Request, post_id: str): """Upload a media item (image or video). Max 3 items per post.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Check authorization: owner or company owner is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") # Get current media items (convert to dicts with JSON-serializable datetime) media_items = [ item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item) for item in (post.media_items or []) ] # Validate: Max 3 items if len(media_items) >= 3: raise HTTPException(status_code=400, detail="Maximal 3 Medien erlaubt") # Read file from form data form = await request.form() file = form.get("file") or form.get("image") # Support both 'file' and 'image' keys if not file or not hasattr(file, "read"): raise HTTPException(status_code=400, detail="No file provided") file_content = await file.read() content_type = file.content_type or "application/octet-stream" # Validate media type consistency (no mixing) is_video = content_type.startswith("video/") has_videos = any(item.get("type") == "video" for item in media_items) has_images = any(item.get("type") == "image" for item in media_items) if (is_video and has_images) or (not is_video and has_videos): raise HTTPException(status_code=400, detail="Kann nicht Bilder und Videos mischen") # Only allow 1 video max if is_video and len(media_items) > 0: raise HTTPException(status_code=400, detail="Nur 1 Video pro Post erlaubt") # Upload to storage media_url = await storage.upload_media( file_content=file_content, content_type=content_type, user_id=str(post.user_id), ) # Add to array new_item = { "type": "video" if is_video else "image", "url": media_url, "order": len(media_items), "content_type": content_type, "uploaded_at": datetime.now(timezone.utc).isoformat(), "metadata": None } media_items.append(new_item) # Update DB await db.update_generated_post(UUID(post_id), {"media_items": media_items}) # Also update image_url for backward compatibility (first image) if new_item["type"] == "image" and len([i for i in media_items if i["type"] == "image"]) == 1: await db.update_generated_post(UUID(post_id), {"image_url": media_url}) return {"success": True, "media_item": new_item, "media_items": media_items} except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.exception(f"Failed to upload post media: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.delete("/api/posts/{post_id}/media/{media_index}") async def delete_post_media(request: Request, post_id: str, media_index: int): """Delete a specific media item by index.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Check authorization: owner or company owner is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") # Get current media items (convert to dicts with JSON-serializable datetime) media_items = [ item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item) for item in (post.media_items or []) ] if media_index < 0 or media_index >= len(media_items): raise HTTPException(status_code=404, detail="Media item nicht gefunden") # Delete from storage item_to_delete = media_items[media_index] try: await storage.delete_image(item_to_delete["url"]) except Exception as e: logger.warning(f"Failed to delete media from storage: {e}") # Remove and re-index media_items.pop(media_index) for i, item in enumerate(media_items): item["order"] = i # Update DB await db.update_generated_post(UUID(post_id), {"media_items": media_items}) # Update image_url for backward compatibility first_image = next((item for item in media_items if item["type"] == "image"), None) await db.update_generated_post(UUID(post_id), {"image_url": first_image["url"] if first_image else None}) return {"success": True, "media_items": media_items} except HTTPException: raise except Exception as e: logger.exception(f"Failed to delete post media: {e}") raise HTTPException(status_code=500, detail=str(e)) @user_router.put("/api/posts/{post_id}/media/reorder") async def reorder_post_media(request: Request, post_id: str): """Reorder media items. Expects JSON: {"order": [2, 0, 1]}""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") try: post = await db.get_generated_post(UUID(post_id)) if not post: raise HTTPException(status_code=404, detail="Post not found") # Check authorization: owner or company owner is_owner = session.user_id and str(post.user_id) == session.user_id is_company_owner = False if not is_owner and session.account_type == "company" and session.company_id: profile = await db.get_profile(post.user_id) if profile and profile.company_id and str(profile.company_id) == session.company_id: is_company_owner = True if not is_owner and not is_company_owner: raise HTTPException(status_code=403, detail="Not authorized") # Get current media items (convert to dicts with JSON-serializable datetime) media_items = [ item.model_dump(mode='json') if hasattr(item, 'model_dump') else (item.dict() if hasattr(item, 'dict') else item) for item in (post.media_items or []) ] # Parse request body body = await request.json() new_order = body.get("order", []) # Validate order array if len(new_order) != len(media_items) or set(new_order) != set(range(len(media_items))): raise HTTPException(status_code=400, detail="Invalid order indices") # Reorder reordered = [media_items[i] for i in new_order] for i, item in enumerate(reordered): item["order"] = i # Update DB await db.update_generated_post(UUID(post_id), {"media_items": reordered}) return {"success": True, "media_items": reordered} except HTTPException: raise except Exception as e: logger.exception(f"Failed to reorder post media: {e}") raise HTTPException(status_code=500, detail=str(e)) # ==================== IMAGE PROXY FOR HTTPS ==================== @user_router.get("/proxy/image/{bucket}/{path:path}") async def proxy_supabase_image(bucket: str, path: str): """ Proxy Supabase storage images via HTTPS to avoid mixed content warnings. This allows HTTPS pages to load images from HTTP Supabase storage. """ import httpx from fastapi.responses import Response try: # Build the Supabase storage URL storage_url = settings.supabase_url.replace("https://", "http://").replace("http://", "http://") image_url = f"{storage_url}/storage/v1/object/public/{bucket}/{path}" # Fetch the image from Supabase async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(image_url) if response.status_code != 200: raise HTTPException(status_code=404, detail="Image not found") # Return the image with proper headers return Response( content=response.content, media_type=response.headers.get("content-type", "image/jpeg"), headers={ "Cache-Control": "public, max-age=31536000", "Access-Control-Allow-Origin": "*" } ) except httpx.TimeoutException: raise HTTPException(status_code=504, detail="Image fetch timeout") except Exception as e: logger.error(f"Failed to proxy image {bucket}/{path}: {e}") raise HTTPException(status_code=500, detail="Failed to load image") # ==================== TELEGRAM BOT ==================== if settings.telegram_enabled: import secrets as _secrets from src.services.telegram_service import telegram_service as _telegram_service @user_router.get("/settings/telegram/start") async def telegram_start_link(request: Request): """Generate a one-time Telegram linking token and return the bot link.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") from src.services.redis_client import get_redis token = _secrets.token_urlsafe(32) try: redis = await get_redis() await redis.setex(f"telegram_link:{token}", 600, session.user_id) except Exception as e: logger.error(f"Failed to store telegram link token: {e}") raise HTTPException(status_code=500, detail="Failed to generate link") return JSONResponse({ "bot_username": settings.telegram_bot_username, "token": token }) @user_router.post("/api/telegram/webhook") async def telegram_webhook(request: Request): """Receive Telegram webhook updates.""" secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") if secret != settings.telegram_webhook_secret: raise HTTPException(status_code=403, detail="Invalid secret") try: update = await request.json() if _telegram_service: await _telegram_service.handle_update(update, db) except Exception as e: logger.error(f"Telegram webhook error: {e}") # Always return 200 — Telegram requires it return {"ok": True} @user_router.post("/api/settings/telegram/disconnect") async def telegram_disconnect(request: Request): """Disconnect Telegram account.""" session = require_user_session(request) if not session: raise HTTPException(status_code=401, detail="Not authenticated") await db.delete_telegram_account(UUID(session.user_id)) return JSONResponse({"success": True})