3751 lines
140 KiB
Python
3751 lines
140 KiB
Python
"""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_decision_notification_email,
|
|
validate_token,
|
|
mark_token_used
|
|
)
|
|
from src.services.background_jobs import (
|
|
job_manager, JobType, JobStatus,
|
|
run_post_scraping, run_profile_analysis, run_post_categorization, run_post_type_analysis,
|
|
run_full_analysis_pipeline
|
|
)
|
|
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 from user profile record (cached)."""
|
|
profile = await db.get_profile(user_id)
|
|
if profile and profile.profile_picture:
|
|
return profile.profile_picture
|
|
return None
|
|
|
|
|
|
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 = 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 = 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":
|
|
company = await db.get_company(UUID(session.company_id)) if session.company_id else None
|
|
employees = await db.get_company_employees(UUID(session.company_id)) if session.company_id else []
|
|
pending_invitations = await db.get_pending_invitations(UUID(session.company_id)) if session.company_id else []
|
|
quota = await db.get_company_daily_quota(UUID(session.company_id)) if session.company_id else None
|
|
license_key = await db.get_company_limits(UUID(session.company_id)) if session.company_id else None
|
|
|
|
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
|
|
})
|
|
|
|
# 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 = session.linkedin_picture or await get_user_profile_picture(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)
|
|
})
|
|
|
|
|
|
@user_router.get("/posts", response_class=HTMLResponse)
|
|
async def posts_page(request: Request):
|
|
"""View user's own posts."""
|
|
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)
|
|
posts = await db.get_generated_posts(user_id)
|
|
profile_picture = session.linkedin_picture or await get_user_profile_picture(user_id)
|
|
|
|
return templates.TemplateResponse("posts.html", {
|
|
"request": request,
|
|
"page": "posts",
|
|
"session": session,
|
|
"profile": profile,
|
|
"posts": posts,
|
|
"total_posts": len(posts),
|
|
"profile_picture": profile_picture
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error loading posts: {e}")
|
|
return templates.TemplateResponse("posts.html", {
|
|
"request": request,
|
|
"page": "posts",
|
|
"session": session,
|
|
"posts": [],
|
|
"total_posts": 0,
|
|
"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)
|
|
|
|
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
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error loading post types page: {e}")
|
|
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)
|
|
})
|
|
|
|
|
|
@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."""
|
|
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():
|
|
queue = asyncio.Queue()
|
|
|
|
async def on_job_update(job):
|
|
await queue.put(job)
|
|
|
|
# Register listener
|
|
job_manager.add_listener(tracking_id, on_job_update)
|
|
|
|
try:
|
|
# Send initial active jobs
|
|
active_jobs = job_manager.get_active_jobs(tracking_id)
|
|
for job in active_jobs:
|
|
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 updates
|
|
while True:
|
|
try:
|
|
job = await asyncio.wait_for(queue.get(), timeout=30)
|
|
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"
|
|
except asyncio.TimeoutError:
|
|
# Send keepalive
|
|
yield ": keepalive\n\n"
|
|
finally:
|
|
job_manager.remove_listener(tracking_id, on_job_update)
|
|
|
|
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 = 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 = await db.get_profile(post.user_id)
|
|
linkedin_posts = await db.get_linkedin_posts(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_picture_url = session.linkedin_picture
|
|
if not profile_picture_url:
|
|
for lp in linkedin_posts:
|
|
if lp.raw_data and isinstance(lp.raw_data, dict):
|
|
author = lp.raw_data.get("author", {})
|
|
if author and isinstance(author, dict):
|
|
profile_picture_url = author.get("profile_picture")
|
|
if profile_picture_url:
|
|
break
|
|
|
|
profile_analysis_record = await db.get_profile_analysis(post.user_id)
|
|
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
|
|
]
|
|
|
|
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,
|
|
"media_items_dict": media_items_dict
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error loading post detail: {e}")
|
|
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 research 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_research_limit(UUID(session.company_id))
|
|
limit_reached = not can_create
|
|
limit_message = error_msg
|
|
|
|
return templates.TemplateResponse("research.html", {
|
|
"request": request,
|
|
"page": "research",
|
|
"session": session,
|
|
"user_id": session.user_id,
|
|
"limit_reached": limit_reached,
|
|
"limit_message": limit_message
|
|
})
|
|
|
|
|
|
@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 post 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_post_limit(UUID(session.company_id))
|
|
limit_reached = not can_create
|
|
limit_message = error_msg
|
|
|
|
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
|
|
})
|
|
|
|
|
|
@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 = session.linkedin_picture or await get_user_profile_picture(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 RESEARCH LIMIT and capture company_id for quota tracking
|
|
quota_company_id = None
|
|
if session.account_type in ("company", "employee") and session.company_id:
|
|
quota_company_id = UUID(session.company_id)
|
|
can_create, error_msg = await db.check_company_research_limit(quota_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
|
|
)
|
|
|
|
# INCREMENT COMPANY QUOTA after successful research
|
|
if quota_company_id:
|
|
try:
|
|
await db.increment_company_researches_quota(quota_company_id)
|
|
logger.info(f"Incremented research quota for company {quota_company_id}")
|
|
except Exception as quota_error:
|
|
logger.error(f"Failed to increment research quota: {quota_error}")
|
|
|
|
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 POST LIMIT and capture company_id for quota tracking
|
|
quota_company_id = None
|
|
if session.account_type in ("company", "employee") and session.company_id:
|
|
quota_company_id = UUID(session.company_id)
|
|
can_create, error_msg = await db.check_company_post_limit(quota_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
|
|
)
|
|
|
|
# INCREMENT COMPANY QUOTA after successful creation
|
|
if quota_company_id:
|
|
try:
|
|
await db.increment_company_posts_quota(quota_company_id)
|
|
logger.info(f"Incremented post quota for company {quota_company_id}")
|
|
except Exception as quota_error:
|
|
logger.error(f"Failed to increment post quota: {quota_error}")
|
|
|
|
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")
|
|
|
|
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
|
|
if str(post.user_id) != session.user_id:
|
|
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")
|
|
|
|
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
|
|
if str(post.user_id) != session.user_id:
|
|
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")
|
|
|
|
# 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})
|
|
|
|
# Send email when moving to "approved" (Bearbeitet)
|
|
email_sent = False
|
|
if status == "approved" and profile and profile.customer_email:
|
|
# Build base URL from request
|
|
base_url = str(request.base_url).rstrip('/')
|
|
email_sent = send_approval_request_email(
|
|
to_email=profile.customer_email,
|
|
post_id=UUID(post_id),
|
|
post_title=post.topic_title or "Untitled Post",
|
|
post_content=post.post_content,
|
|
base_url=base_url,
|
|
image_url=post.image_url
|
|
)
|
|
|
|
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
|
|
if str(post.user_id) != session.user_id:
|
|
raise HTTPException(status_code=403, detail="Not authorized")
|
|
|
|
# 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 = 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})
|
|
|
|
# Mark token as used
|
|
mark_token_used(token)
|
|
|
|
# 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:
|
|
profile = await db.get_profile(UUID(session.user_id))
|
|
profile_picture = session.linkedin_picture or await get_user_profile_picture(UUID(session.user_id))
|
|
|
|
# Get LinkedIn account if linked
|
|
linkedin_account = await db.get_linkedin_account(UUID(session.user_id))
|
|
|
|
return templates.TemplateResponse("settings.html", {
|
|
"request": request,
|
|
"page": "settings",
|
|
"session": session,
|
|
"profile": profile,
|
|
"profile_picture": profile_picture,
|
|
"linkedin_account": linkedin_account
|
|
})
|
|
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))
|
|
|
|
|
|
# ==================== 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 {}
|
|
|
|
return templates.TemplateResponse("company_strategy.html", {
|
|
"request": request,
|
|
"page": "strategy",
|
|
"session": session,
|
|
"company": company,
|
|
"strategy": strategy,
|
|
"success": success
|
|
})
|
|
|
|
|
|
@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)
|
|
profile_picture = session.linkedin_picture
|
|
|
|
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
|
|
# Note: emp is a User object from get_company_employees which has linkedin_name and linkedin_picture
|
|
active_employees_info = []
|
|
for emp in active_employees:
|
|
active_employees_info.append({
|
|
"id": str(emp.id),
|
|
"email": emp.email,
|
|
"display_name": emp.linkedin_name or emp.display_name or emp.email,
|
|
"linkedin_picture": emp.linkedin_picture,
|
|
"onboarding_status": emp.onboarding_status
|
|
})
|
|
|
|
# Selected employee data
|
|
selected_employee = None
|
|
employee_posts = []
|
|
pending_posts = 0
|
|
approved_posts = 0
|
|
|
|
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:
|
|
# Get employee's user_id
|
|
emp_profile = await db.get_profile(UUID(employee_id))
|
|
if emp_profile:
|
|
employee_posts = await db.get_generated_posts(emp_profile.id)
|
|
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']])
|
|
|
|
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
|
|
})
|
|
|
|
|
|
@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)
|
|
|
|
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)
|
|
})
|
|
|
|
|
|
@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
|
|
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)
|
|
|
|
post = await db.get_generated_post(UUID(post_id))
|
|
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 = await db.get_profile(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
|
|
]
|
|
|
|
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
|
|
})
|
|
|
|
|
|
@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 research limit
|
|
limit_reached = False
|
|
limit_message = ""
|
|
if session.company_id:
|
|
can_create, error_msg = await db.check_company_research_limit(UUID(session.company_id))
|
|
limit_reached = not can_create
|
|
limit_message = error_msg
|
|
|
|
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
|
|
})
|
|
|
|
|
|
@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 post limit
|
|
limit_reached = False
|
|
limit_message = ""
|
|
if session.company_id:
|
|
can_create, error_msg = await db.check_company_post_limit(UUID(session.company_id))
|
|
limit_reached = not can_create
|
|
limit_message = error_msg
|
|
|
|
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
|
|
})
|
|
|
|
|
|
# ==================== 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 {}
|
|
|
|
return templates.TemplateResponse("employee_strategy.html", {
|
|
"request": request,
|
|
"page": "strategy",
|
|
"session": session,
|
|
"company": company,
|
|
"strategy": strategy
|
|
})
|
|
|
|
|
|
@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 = await db.get_scheduled_posts_for_company(company_id)
|
|
|
|
# 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"),
|
|
"user_id": post.get("employee_user_id")
|
|
}
|
|
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
|
|
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("/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")
|
|
|
|
# Only company owners can schedule for now
|
|
if session.account_type != "company" or not session.company_id:
|
|
raise HTTPException(status_code=403, detail="Only company owners can schedule posts")
|
|
|
|
try:
|
|
post = await db.get_generated_post(UUID(post_id))
|
|
if not post:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
|
|
# Verify this post belongs to a company employee
|
|
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")
|
|
|
|
# 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")
|
|
|
|
# Only company owners can unschedule for now
|
|
if session.account_type != "company" or not session.company_id:
|
|
raise HTTPException(status_code=403, detail="Only company owners can unschedule posts")
|
|
|
|
try:
|
|
post = await db.get_generated_post(UUID(post_id))
|
|
if not post:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
|
|
# Verify this post belongs to a company employee
|
|
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")
|
|
|
|
# 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")
|