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