Improved features, implemented moco integration

This commit is contained in:
2026-02-18 19:59:14 +01:00
parent af2c9e7fd8
commit 8e4f155a16
11 changed files with 827 additions and 427 deletions

BIN
Angebot.pdf Normal file

Binary file not shown.

View File

@@ -61,6 +61,10 @@ class Settings(BaseSettings):
# Token Encryption # Token Encryption
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# MOCO Integration
moco_api_key: str = "" # Token für Authorization-Header
moco_domain: str = "" # Subdomain: {domain}.mocoapp.com
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View File

@@ -11,7 +11,7 @@ from src.database.models import (
LinkedInProfile, LinkedInPost, Topic, LinkedInProfile, LinkedInPost, Topic,
ProfileAnalysis, ResearchResult, GeneratedPost, PostType, ProfileAnalysis, ResearchResult, GeneratedPost, PostType,
User, Profile, Company, Invitation, ExamplePost, ReferenceProfile, User, Profile, Company, Invitation, ExamplePost, ReferenceProfile,
ApiUsageLog, LicenseKey, CompanyDailyQuota ApiUsageLog, LicenseKey, CompanyDailyQuota, LicenseKeyOffer
) )
@@ -1252,6 +1252,59 @@ class DatabaseClient:
) )
logger.info(f"Deleted license key: {key_id}") logger.info(f"Deleted license key: {key_id}")
# ==================== LICENSE KEY OFFERS ====================
async def create_license_key_offer(
self,
license_key_id: UUID,
moco_offer_id: int,
moco_offer_identifier: Optional[str],
moco_offer_url: Optional[str],
offer_title: Optional[str],
company_name: Optional[str],
price: Optional[float],
payment_frequency: Optional[str],
) -> "LicenseKeyOffer":
"""Save a MOCO offer linked to a license key."""
data = {
"license_key_id": str(license_key_id),
"moco_offer_id": moco_offer_id,
"moco_offer_identifier": moco_offer_identifier,
"moco_offer_url": moco_offer_url,
"offer_title": offer_title,
"company_name": company_name,
"price": price,
"payment_frequency": payment_frequency,
"status": "draft",
}
result = await asyncio.to_thread(
lambda: self.client.table("license_key_offers").insert(data).execute()
)
return LicenseKeyOffer(**result.data[0])
async def list_license_key_offers(self, license_key_id: UUID) -> list["LicenseKeyOffer"]:
"""List all MOCO offers for a license key."""
result = await asyncio.to_thread(
lambda: self.client.table("license_key_offers")
.select("*")
.eq("license_key_id", str(license_key_id))
.order("created_at", desc=True)
.execute()
)
return [LicenseKeyOffer(**row) for row in result.data]
async def update_license_key_offer_status(
self, offer_id: UUID, status: str
) -> "LicenseKeyOffer":
"""Update the status of a stored offer."""
result = await asyncio.to_thread(
lambda: self.client.table("license_key_offers")
.update({"status": status})
.eq("id", str(offer_id))
.execute()
)
return LicenseKeyOffer(**result.data[0])
# ==================== COMPANY QUOTAS ==================== # ==================== COMPANY QUOTAS ====================
async def get_company_daily_quota( async def get_company_daily_quota(
@@ -1356,6 +1409,66 @@ class DatabaseClient:
return True, "" return True, ""
# ==================== EMAIL ACTION TOKENS ====================
async def create_email_token(self, token: str, post_id: UUID, action: str, expires_hours: int = 72) -> None:
"""Store an email action token in the database."""
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
data = {
"token": token,
"post_id": str(post_id),
"action": action,
"expires_at": expires_at.isoformat(),
"used": False,
}
await asyncio.to_thread(
lambda: self.client.table("email_action_tokens").insert(data).execute()
)
logger.debug(f"Created email token for post {post_id} action={action}")
async def get_email_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Retrieve email token data; returns None if not found."""
result = await asyncio.to_thread(
lambda: self.client.table("email_action_tokens").select("*").eq("token", token).execute()
)
if not result.data:
return None
return result.data[0]
async def mark_email_token_used(self, token: str) -> None:
"""Mark an email token as used."""
await asyncio.to_thread(
lambda: self.client.table("email_action_tokens").update({"used": True}).eq("token", token).execute()
)
async def cleanup_expired_email_tokens(self) -> None:
"""Delete expired email tokens from the database."""
now = datetime.now(timezone.utc).isoformat()
result = await asyncio.to_thread(
lambda: self.client.table("email_action_tokens").delete().lt("expires_at", now).execute()
)
count = len(result.data) if result.data else 0
if count:
logger.info(f"Cleaned up {count} expired email tokens")
# ==================== LINKEDIN TOKEN REFRESH ====================
async def get_expiring_linkedin_accounts(self, within_days: int = 7) -> list:
"""Return active LinkedIn accounts whose tokens expire within within_days and have a refresh_token."""
from src.database.models import LinkedInAccount
from datetime import timedelta
cutoff = (datetime.now(timezone.utc) + timedelta(days=within_days)).isoformat()
result = await asyncio.to_thread(
lambda: self.client.table("linkedin_accounts")
.select("*")
.eq("is_active", True)
.lt("token_expires_at", cutoff)
.not_.is_("refresh_token", "null")
.execute()
)
return [LinkedInAccount(**row) for row in result.data]
# Global database client instance # Global database client instance
db = DatabaseClient() db = DatabaseClient()

View File

@@ -376,6 +376,21 @@ class LicenseKey(DBModel):
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
class LicenseKeyOffer(DBModel):
"""MOCO offer created for a license key."""
id: Optional[UUID] = None
license_key_id: UUID
moco_offer_id: int
moco_offer_identifier: Optional[str] = None
moco_offer_url: Optional[str] = None
offer_title: Optional[str] = None
company_name: Optional[str] = None
price: Optional[float] = None
payment_frequency: Optional[str] = None
status: str = "draft"
created_at: Optional[datetime] = None
class CompanyDailyQuota(DBModel): class CompanyDailyQuota(DBModel):
"""Daily usage quota for a company.""" """Daily usage quota for a company."""
id: Optional[UUID] = None id: Optional[UUID] = None

View File

@@ -6,48 +6,49 @@ from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email import encoders from email import encoders
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from loguru import logger from loguru import logger
from src.config import settings from src.config import settings
# In-memory token store (in production, use Redis or database)
_email_tokens: Dict[str, Dict[str, Any]] = {}
async def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str: """Generate a unique token for email action and persist it to the database."""
"""Generate a unique token for email action.""" from src.database.client import db
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
_email_tokens[token] = { await db.create_email_token(token, post_id, action, expires_hours)
"post_id": str(post_id),
"action": action,
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
"used": False
}
return token return token
def validate_token(token: str) -> Optional[Dict[str, Any]]: async def validate_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate and return token data if valid.""" """Validate and return token data if valid (checks DB)."""
if token not in _email_tokens: from src.database.client import db
token_data = await db.get_email_token(token)
if not token_data:
return None return None
token_data = _email_tokens[token] if token_data.get("used"):
if token_data["used"]:
return None return None
if datetime.utcnow() > token_data["expires_at"]: expires_at_raw = token_data.get("expires_at")
return None if expires_at_raw:
if isinstance(expires_at_raw, str):
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
else:
expires_at = expires_at_raw
if not expires_at.tzinfo:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > expires_at:
return None
return token_data return token_data
def mark_token_used(token: str): async def mark_token_used(token: str) -> None:
"""Mark a token as used.""" """Mark a token as used in the database."""
if token in _email_tokens: from src.database.client import db
_email_tokens[token]["used"] = True await db.mark_email_token_used(token)
def send_email(to_email: str, subject: str, html_content: str) -> bool: def send_email(to_email: str, subject: str, html_content: str) -> bool:
@@ -119,7 +120,7 @@ def send_email_with_attachment(
return False return False
def send_approval_request_email( async def send_approval_request_email(
to_email: str, to_email: str,
post_id: UUID, post_id: UUID,
post_title: str, post_title: str,
@@ -128,8 +129,8 @@ def send_approval_request_email(
image_url: Optional[str] = None image_url: Optional[str] = None
) -> bool: ) -> bool:
"""Send email to customer requesting approval of a post.""" """Send email to customer requesting approval of a post."""
approve_token = generate_token(post_id, "approve") approve_token = await generate_token(post_id, "approve")
reject_token = generate_token(post_id, "reject") reject_token = await generate_token(post_id, "reject")
approve_url = f"{base_url}/api/email-action/{approve_token}" approve_url = f"{base_url}/api/email-action/{approve_token}"
reject_url = f"{base_url}/api/email-action/{reject_token}" reject_url = f"{base_url}/api/email-action/{reject_token}"

View File

@@ -0,0 +1,272 @@
"""MOCO API integration for offer creation."""
from datetime import datetime, timedelta
from typing import Optional
import httpx
from loguru import logger
from src.config import settings
def _headers() -> dict:
return {
"Authorization": f"Token token={settings.moco_api_key}",
"Content-Type": "application/json",
}
def _base_url() -> str:
return f"https://{settings.moco_domain}.mocoapp.com/api/v1"
async def search_moco_companies(term: str) -> list[dict]:
"""Search MOCO companies by name term.
Returns a list of dicts with keys: id, name.
"""
if not settings.moco_api_key or not settings.moco_domain:
logger.warning("MOCO not configured (moco_api_key / moco_domain missing)")
return []
params = {"type": "customer"}
if term:
params["term"] = term
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{_base_url()}/companies",
headers=_headers(),
params=params,
)
response.raise_for_status()
companies = response.json()
return [{"id": c["id"], "name": c["name"]} for c in companies]
except httpx.HTTPStatusError as e:
logger.error(f"MOCO companies search failed ({e.response.status_code}): {e.response.text}")
raise
except Exception as e:
logger.error(f"MOCO companies search error: {e}")
raise
async def _get_company_details(company_id: int) -> Optional[dict]:
"""Fetch a MOCO company by ID. Returns the company dict or None."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{_base_url()}/companies/{company_id}",
headers=_headers(),
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Could not fetch MOCO company {company_id}: {e}")
return None
async def _get_company_contact(company_id: int, company_name: str) -> Optional[dict]:
"""Return the first contact person associated with the given MOCO company.
MOCO's /contacts/people has no company_id filter, so we search by company
name and then filter client-side on company.id.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{_base_url()}/contacts/people",
headers=_headers(),
params={"term": company_name},
)
response.raise_for_status()
people = response.json()
# Filter to contacts whose company.id matches exactly
matched = [
p for p in people
if (p.get("company") or {}).get("id") == company_id
]
contact = matched[0] if matched else None
if contact:
logger.debug(
f"MOCO contact for company {company_id}: "
f"gender={contact.get('gender')!r} lastname={contact.get('lastname')!r}"
)
else:
logger.debug(f"No contact found for MOCO company {company_id} ({company_name!r})")
return contact
except Exception as e:
logger.warning(f"Could not fetch MOCO contacts for company {company_id}: {e}")
return None
def _build_salutation_html(contact: Optional[dict]) -> str:
"""Build an HTML salutation block from a MOCO contact dict."""
if not contact:
greeting = "Sehr geehrte Damen und Herren"
else:
gender = (contact.get("gender") or "").strip().upper()
lastname = (contact.get("lastname") or "").strip()
logger.debug(f"MOCO contact gender={gender!r} lastname={lastname!r}")
if gender == "M":
greeting = f"Sehr geehrter Herr {lastname}".strip()
elif gender == "F":
greeting = f"Sehr geehrte Frau {lastname}".strip()
else:
greeting = "Sehr geehrte Damen und Herren"
return (
f"<p>{greeting},</p>"
"<p>vielen Dank für Ihre Anfrage. Anbei finden Sie ein ausgearbeitetes Angebot "
"für die von Ihnen angefragten Leistungen.</p>"
)
def _build_footer_html() -> str:
"""Build the HTML footer using MOCO's native {customer_approval_link} variable.
MOCO replaces {customer_approval_link} with a properly formatted and
clickable link at render/send time. Using a manual <a> tag causes MOCO
to override the link text with its own '[identifier] Angebot' format.
"""
return (
"<p>Wir freuen uns über Ihre Bestätigung per E-Mail oder direkt über diesen Link: "
"{customer_approval_link}</p>"
"<p>Freundliche Grüße,<br>"
"Olivia Kibele<br>"
"olivia.kibele@onyva.de<br>"
"0751 18523411</p>"
)
async def create_moco_offer(
company_id: int,
company_name: str,
price: float,
payment_frequency: str,
plan_name: str,
max_employees: int,
daily_token_limit: Optional[int],
) -> dict:
"""Create an offer in MOCO and return the full offer object."""
today = datetime.utcnow().date()
due_date = today + timedelta(days=30)
# Fetch company details for billing address + contact lookup
company = await _get_company_details(company_id)
recipient_address = (
(company.get("address") or "").strip() or company_name
if company else company_name
)
contact = await _get_company_contact(company_id, company_name)
salutation_html = _build_salutation_html(contact)
if daily_token_limit:
posts_per_month = int(daily_token_limit * 30 / 50_000)
token_detail = (
f"{daily_token_limit:,}".replace(",", ".") +
f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
)
else:
token_detail = "Unbegrenzt"
freq_label = payment_frequency.capitalize()
offer_title = f"LinkedIn Content Automation {plan_name}"
description_text = (
f"LinkedIn Content Automation {plan_name}\n\n"
f"Lizenzdetails:\n"
f"• Mitarbeiter: {max_employees} Nutzer\n"
f"• KI-Token-Limit: {token_detail}\n"
f"• Zahlungsweise: {freq_label}\n\n"
f"Enthaltene Funktionen:\n"
f"• LinkedIn-Profil-Analyse (KI-gestützt)\n"
f"• Post-Typen & Strategie mit Gewichtung\n"
f"• KI-Post-Erstellung (chat-basiert, iterativ)\n"
f"• Mitarbeiterverwaltung & Freigabe-Workflows\n"
f"• Research-Funktion & Posting-Kalender\n"
f"• E-Mail-Benachrichtigungen"
)
payload = {
"company_id": company_id,
"recipient_address": recipient_address,
"date": today.isoformat(),
"due_date": due_date.isoformat(),
"title": offer_title,
"tax": 19.0,
"currency": "EUR",
"discount": 0,
"salutation": salutation_html,
"footer": _build_footer_html(),
"items": [
{
"type": "description",
"title": "Leistungsbeschreibung",
"description": description_text,
},
{
"type": "item",
"title": f"Lizenz {plan_name}",
"description": f"{max_employees} Nutzer · {token_detail} · {freq_label}",
"quantity": 1,
"unit": "Monat" if payment_frequency == "monatlich" else "Pauschal",
"unit_price": price,
"net_total": price,
},
],
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
f"{_base_url()}/offers",
headers=_headers(),
json=payload,
)
response.raise_for_status()
offer = response.json()
logger.info(
f"MOCO offer created: id={offer.get('id')}, identifier={offer.get('identifier')}"
)
return offer
except httpx.HTTPStatusError as e:
logger.error(f"MOCO offer creation failed ({e.response.status_code}): {e.response.text}")
raise
except Exception as e:
logger.error(f"MOCO offer creation error: {e}")
raise
async def send_moco_offer(moco_offer_id: int) -> None:
"""Send a MOCO offer to its default recipients via email.
Uses POST /offers/{id}/send_email with empty recipient lists so MOCO
falls back to the default recipients configured on the customer.
"""
payload = {
"subject": "Ihr Angebot LinkedIn Content Automation",
"text": (
"Anbei finden Sie unser Angebot für die LinkedIn Content Automation Plattform.\n\n"
"Bitte bestätigen Sie direkt über den Link im Angebot.\n\n"
"Freundliche Grüße,\nOlivia Kibele"
),
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
f"{_base_url()}/offers/{moco_offer_id}/send_email",
headers=_headers(),
json=payload,
)
response.raise_for_status()
logger.info(f"MOCO offer {moco_offer_id} sent via email")
except httpx.HTTPStatusError as e:
logger.error(f"MOCO send_email failed ({e.response.status_code}): {e.response.text}")
raise
except Exception as e:
logger.error(f"MOCO send_email error: {e}")
raise

View File

@@ -54,14 +54,44 @@ class SchedulerService:
async def _run_loop(self): async def _run_loop(self):
"""Main scheduler loop.""" """Main scheduler loop."""
_tick = 0
while self._running: while self._running:
try: try:
await self._process_due_posts() await self._process_due_posts()
except Exception as e: except Exception as e:
logger.error(f"Scheduler error: {e}") logger.error(f"Scheduler error: {e}")
# Daily jobs (every 1440 ticks = 24h at 60s interval)
if _tick % 1440 == 0:
try:
await self._refresh_expiring_tokens()
except Exception as e:
logger.error(f"Token refresh job error: {e}")
try:
await self.db.cleanup_expired_email_tokens()
except Exception as e:
logger.error(f"Email token cleanup error: {e}")
_tick += 1
await asyncio.sleep(self.check_interval) await asyncio.sleep(self.check_interval)
async def _refresh_expiring_tokens(self):
"""Proactively refresh LinkedIn tokens expiring within 7 days."""
from src.services.linkedin_service import linkedin_service
accounts = await self.db.get_expiring_linkedin_accounts(within_days=7)
if not accounts:
return
logger.info(f"Refreshing {len(accounts)} expiring LinkedIn tokens")
for account in accounts:
try:
refreshed = await linkedin_service.refresh_access_token(account.id)
if refreshed:
logger.info(f"Token refresh ok: {account.id}")
else:
logger.warning(f"Token refresh failed: {account.id}")
except Exception as e:
logger.error(f"Token refresh error {account.id}: {e}")
async def _process_due_posts(self): async def _process_due_posts(self):
"""Process all posts that are due for publishing.""" """Process all posts that are due for publishing."""
due_posts = await self.db.get_scheduled_posts_due() due_posts = await self.db.get_scheduled_posts_due()

View File

@@ -1,9 +1,9 @@
"""Admin panel routes (password-protected) - User Management & Statistics.""" """Admin panel routes (password-protected) - User Management & Statistics."""
import asyncio
import re import re
import secrets import secrets
import math import math
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
@@ -14,19 +14,13 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from loguru import logger from loguru import logger
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from src.database import db from src.database import db
from src.web.admin.auth import ( from src.web.admin.auth import (
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
) )
from src.web.user.auth import UserSession, set_user_session from src.web.user.auth import UserSession, set_user_session
from src.services.email_service import send_email_with_attachment
from src.config import settings from src.config import settings
from src.services import moco_service
# Router with /admin prefix # Router with /admin prefix
admin_router = APIRouter(prefix="/admin", tags=["admin"]) admin_router = APIRouter(prefix="/admin", tags=["admin"])
@@ -534,10 +528,20 @@ async def license_keys_page(request: Request):
try: try:
keys = await db.list_license_keys() keys = await db.list_license_keys()
# Load offers for all keys in parallel
offers_lists = await asyncio.gather(
*[db.list_license_key_offers(k.id) for k in keys],
return_exceptions=True
)
offers_by_key = {
str(k.id): (v if not isinstance(v, Exception) else [])
for k, v in zip(keys, offers_lists)
}
return templates.TemplateResponse("license_keys.html", { return templates.TemplateResponse("license_keys.html", {
"request": request, "request": request,
"page": "license_keys", "page": "license_keys",
"keys": keys, "keys": keys,
"offers_by_key": offers_by_key,
"total_keys": len(keys), "total_keys": len(keys),
"used_keys": len([k for k in keys if k.used]), "used_keys": len([k for k in keys if k.used]),
"available_keys": len([k for k in keys if not k.used]) "available_keys": len([k for k in keys if not k.used])
@@ -626,255 +630,33 @@ async def delete_license_key_route(request: Request, key_id: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ==================== LICENSE KEY OFFER ==================== # ==================== LICENSE KEY OFFER (MOCO) ====================
AVG_TOKENS_PER_POST = 50_000
SERVER_COST_EUR = 16.0
SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer
API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR
def _fmt_eur(amount: float) -> str: @admin_router.get("/api/moco/companies")
"""Format a float as German EUR string, e.g. 1.250,00 €""" async def search_moco_companies_endpoint(request: Request, term: str = ""):
return f"{amount:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") """Live-search MOCO companies by name."""
if not verify_auth(request):
raise HTTPException(status_code=401)
try:
companies = await moco_service.search_moco_companies(term)
return JSONResponse(companies)
except Exception as e:
logger.error(f"MOCO company search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
def _build_offer_pdf( @admin_router.post("/api/license-keys/{key_id}/create-offer")
key_description: str, async def create_moco_offer_endpoint(
max_employees: int,
daily_token_limit: Optional[int],
price: float,
payment_frequency: str,
recipient_email: str,
) -> bytes:
"""Generate a professional, simple PDF offer with logo."""
buffer = BytesIO()
PAGE_W, PAGE_H = A4
MARGIN = 1.8 * cm
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=MARGIN,
leftMargin=MARGIN,
topMargin=MARGIN,
bottomMargin=MARGIN,
)
# ── Colors ────────────────────────────────────────────────────────────────
C_DARK = colors.HexColor("#2d3838")
C_HIGHLIGHT = colors.HexColor("#ffc700")
C_GRAY = colors.HexColor("#666666")
C_LGRAY = colors.HexColor("#f5f5f5")
C_RULE = colors.HexColor("#e0e0e0")
C_WHITE = colors.white
CONTENT_W = PAGE_W - 2 * MARGIN
HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid
# ── Styles ────────────────────────────────────────────────────────────────
s = getSampleStyleSheet()
def ps(name, **kw):
return ParagraphStyle(name, parent=s["Normal"], **kw)
s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14)
s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12)
s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13)
s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold")
s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold",
spaceBefore=0, spaceAfter=0, letterSpacing=0.8)
s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold")
s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold")
s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11)
# ── Computed values ───────────────────────────────────────────────────────
today = datetime.utcnow().strftime("%d.%m.%Y")
validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y")
offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}"
plan_name = key_description or "Standard Plan"
price_str = _fmt_eur(price)
freq_cap = payment_frequency.capitalize()
if daily_token_limit:
posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST)
token_str = (
f"{daily_token_limit:,}".replace(",", ".") +
f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)"
)
else:
token_str = "Unbegrenzt"
story = []
# ── Header: Logo + ANGEBOT ────────────────────────────────────────────────
logo_path = Path(__file__).parent.parent / "static" / "logo.png"
logo = (
Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional")
if logo_path.exists()
else Paragraph("<b>Onyva</b>", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold"))
)
hdr = Table(
[[
logo,
[
Paragraph('<font color="#ffc700"><b>ANGEBOT</b></font>',
ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)),
Spacer(1, 0.1 * cm),
Paragraph(f'<font color="#aaaaaa">{offer_number}</font>',
ps("anr", fontSize=8, textColor=C_GRAY, alignment=2, leading=12)),
],
]],
colWidths=[4.5 * cm, CONTENT_W - 4.5 * cm],
)
hdr.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
("LEFTPADDING", (0, 0), (0, -1), 14),
("RIGHTPADDING", (-1, 0), (-1, -1), 14),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(hdr)
story.append(Spacer(1, 0.45 * cm))
# ── Meta block (recipient / date / validity) ──────────────────────────────
meta = Table(
[
[Paragraph("Empfänger", s_label), Paragraph(recipient_email, s_value),
Paragraph("Datum", s_label), Paragraph(today, s_body)],
[Paragraph("Plan", s_label), Paragraph(plan_name, s_body),
Paragraph("Gültig bis",s_label), Paragraph(validity, s_body)],
],
colWidths=[2.8 * cm, CONTENT_W / 2 - 2.8 * cm, 2.2 * cm, CONTENT_W / 2 - 2.2 * cm],
)
meta.setStyle(TableStyle([
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]))
story.append(meta)
story.append(Spacer(1, 0.3 * cm))
story.append(HRFlowable(width="100%", thickness=1.5, color=C_HIGHLIGHT, spaceAfter=0.35 * cm))
# ── Intro (one compact line) ───────────────────────────────────────────────
story.append(Paragraph(
"Sehr geehrte Damen und Herren, nachfolgend erhalten Sie unser Angebot für die Nutzung der "
"<b>Onyva LinkedIn Content Automation</b> Plattform.",
s_body,
))
story.append(Spacer(1, 0.35 * cm))
# ── Section: Lizenzdetails ────────────────────────────────────────────────
story.append(Paragraph("LIZENZDETAILS", s_sec))
story.append(Spacer(1, 0.15 * cm))
# Compact limits bar (single row, two columns)
limits_bar = Table(
[[
Paragraph(f"<b>Mitarbeiter:</b> {max_employees} Nutzer", s_body),
Paragraph(f"<b>KI-Token-Limit:</b> {token_str}", s_body),
]],
colWidths=[CONTENT_W / 2, CONTENT_W / 2],
)
limits_bar.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING",(0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
story.append(limits_bar)
story.append(Spacer(1, 0.25 * cm))
# Features: 2-column grid
FEATURES = [
("LinkedIn-Profil-Analyse", "KI-gestützt, einmalig pro Profil Basis für individuelle Content-Strategie"),
("Post-Typen & Strategie", "Definierbare Typen mit Gewichtung, KI-Analyse & Strategieanpassung"),
("KI-Post-Erstellung", "Chat-basiert, iterativ, mit Qualitätsprüfung & Stilvalidierung"),
("Mitarbeiterverwaltung", "Posts verwalten, kommentieren, freigeben oder ablehnen per UI"),
("Research-Funktion", "Automatische Themenrecherche zu aktuellen Branchentrends"),
("Posting-Kalender", "Planung & Terminierung von Posts über alle Mitarbeiter hinweg"),
("E-Mail-Workflows", "Freigabe-Anfragen & Entscheidungsbenachrichtigungen per E-Mail"),
]
def feat_cell(name, desc):
return Paragraph(
f'<font color="#2d3838"><b>✓ {name}</b></font><br/>'
f'<font size="7.5" color="#888888"> {desc}</font>',
ps(f"fc_{name[:4]}", fontSize=8.5, leading=13),
)
# Pair features into rows of 2
feat_rows = []
for i in range(0, len(FEATURES), 2):
left = feat_cell(*FEATURES[i])
right = feat_cell(*FEATURES[i + 1]) if i + 1 < len(FEATURES) else Paragraph("", s_small)
feat_rows.append([left, right])
feat_t = Table(feat_rows, colWidths=[HALF_W, HALF_W], hAlign="LEFT")
feat_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("GRID", (0, 0), (-1, -1), 0.4, C_RULE),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
story.append(feat_t)
story.append(Spacer(1, 0.4 * cm))
# ── Section: Preisgestaltung ──────────────────────────────────────────────
story.append(Paragraph("PREISGESTALTUNG", s_sec))
story.append(Spacer(1, 0.15 * cm))
price_rows = [
[Paragraph("Preis", s_label), Paragraph(price_str, s_price)],
[Paragraph("Zahlungsweise", s_label), Paragraph(freq_cap, s_body)],
]
if payment_frequency == "jährlich":
price_rows.append([Paragraph("Jahresbetrag", s_label), Paragraph(_fmt_eur(price * 12), s_body)])
price_t = Table(price_rows, colWidths=[4 * cm, CONTENT_W - 4 * cm])
price_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(price_t)
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph("Alle Preise zzgl. gesetzlicher Mehrwertsteuer.", s_small))
story.append(Spacer(1, 0.5 * cm))
# ── Footer ────────────────────────────────────────────────────────────────
story.append(HRFlowable(width="100%", thickness=0.5, color=C_RULE, spaceAfter=0.25 * cm))
story.append(Paragraph(
"Bei Fragen stehen wir Ihnen gerne zur Verfügung Kontakt: <b>team@onyva.de</b>",
s_small,
))
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Mit freundlichen Grüßen,<br/><b>Onyva</b>", s_body))
doc.build(story)
return buffer.getvalue()
@admin_router.post("/api/license-keys/{key_id}/send-offer")
async def send_license_offer(
request: Request, request: Request,
key_id: str, key_id: str,
email: str = Form(...), company_id: int = Form(...),
company_name: str = Form(...),
price: float = Form(...), price: float = Form(...),
payment_frequency: str = Form(...), payment_frequency: str = Form(...),
): ):
"""Generate a PDF offer and send it via email.""" """Create an offer in MOCO for the given license key."""
if not verify_auth(request): if not verify_auth(request):
raise HTTPException(status_code=401) raise HTTPException(status_code=401)
@@ -883,74 +665,66 @@ async def send_license_offer(
if not key: if not key:
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden") raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
pdf_bytes = _build_offer_pdf( plan_name = key.description or "Standard Plan"
key_description=key.description or "", offer = await moco_service.create_moco_offer(
max_employees=key.max_employees, company_id=company_id,
daily_token_limit=key.daily_token_limit, company_name=company_name,
price=price, price=price,
payment_frequency=payment_frequency, payment_frequency=payment_frequency,
recipient_email=email, plan_name=plan_name,
max_employees=key.max_employees,
daily_token_limit=key.daily_token_limit,
) )
posts_note = "" offer_url = offer.get("web_url") or offer.get("url") or f"https://{settings.moco_domain}.mocoapp.com/offers/{offer.get('id', '')}"
if key.daily_token_limit:
posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST)
token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
else:
token_str = "Unbegrenzt"
price_str = f"{price:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") # Persist offer in DB
plan_name = key.description or "Standard Plan" await db.create_license_key_offer(
license_key_id=UUID(key_id),
html_body = f""" moco_offer_id=offer["id"],
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;"> moco_offer_identifier=offer.get("identifier"),
<div style="background: #2d3838; padding: 24px 32px; border-radius: 8px 8px 0 0;"> moco_offer_url=offer_url,
<h1 style="color: #ffc700; margin: 0; font-size: 22px;">LinkedIn Content Automation</h1> offer_title=offer.get("title") or f"LinkedIn Content Automation {plan_name}",
<p style="color: #cccccc; margin: 6px 0 0; font-size: 13px;">Ihr persönliches Angebot</p> company_name=company_name,
</div> price=price,
<div style="background: #f9f9f9; padding: 32px;"> payment_frequency=payment_frequency,
<p style="color: #333; font-size: 15px;">Sehr geehrte Damen und Herren,</p>
<p style="color: #333; font-size: 14px; line-height: 1.6;">
vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.<br>
Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument.
</p>
<div style="background: #2d3838; border-radius: 8px; padding: 20px 24px; margin: 24px 0;">
<h3 style="color: #ffc700; margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">Angebotsübersicht</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Plan</td><td style="color: #fff; font-size: 13px; font-weight: bold;">{plan_name}</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Mitarbeiter</td><td style="color: #fff; font-size: 13px;">{key.max_employees} Nutzer</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Token-Limit</td><td style="color: #fff; font-size: 13px;">{token_str}</td></tr>
<tr style="border-top: 1px solid #4a5858;"><td style="color: #aaa; font-size: 13px; padding: 8px 0 4px;">Preis</td><td style="color: #ffc700; font-size: 18px; font-weight: bold; padding-top: 6px;">{price_str}</td></tr>
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Zahlungsweise</td><td style="color: #fff; font-size: 13px;">{payment_frequency.capitalize()}</td></tr>
</table>
</div>
<p style="color: #666; font-size: 13px;">Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.</p>
<p style="color: #333; font-size: 14px;">Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
<p style="color: #333; font-size: 14px;">Mit freundlichen Grüßen,<br><strong>Onyva</strong></p>
</div>
<div style="background: #2d3838; padding: 16px 32px; border-radius: 0 0 8px 8px; text-align: center;">
<p style="color: #888; font-size: 11px; margin: 0;">Onyva &bull; team@onyva.de</p>
</div>
</div>
"""
offer_number = datetime.utcnow().strftime('%Y%m%d%H%M')
filename = f"Angebot_{offer_number}.pdf"
success = send_email_with_attachment(
to_email=email,
subject=f"Ihr Angebot LinkedIn Content Automation ({plan_name})",
html_content=html_body,
attachment_bytes=pdf_bytes,
attachment_filename=filename,
) )
if not success: return JSONResponse({"success": True, "offer_url": offer_url})
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden. SMTP prüfen.")
return JSONResponse({"success": True, "message": f"Angebot erfolgreich an {email} gesendet."})
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error sending license offer: {e}") logger.error(f"Error creating MOCO offer: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/api/license-key-offers/{offer_id}/send")
async def send_license_key_offer(request: Request, offer_id: str):
"""Send a stored MOCO offer via email and mark it as sent."""
if not verify_auth(request):
raise HTTPException(status_code=401)
try:
from src.database.models import LicenseKeyOffer
result = await asyncio.to_thread(
lambda: db.client.table("license_key_offers")
.select("*")
.eq("id", offer_id)
.execute()
)
if not result.data:
raise HTTPException(status_code=404, detail="Angebot nicht gefunden")
stored = LicenseKeyOffer(**result.data[0])
await moco_service.send_moco_offer(stored.moco_offer_id)
await db.update_license_key_offer_status(UUID(offer_id), "sent")
return JSONResponse({"success": True})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending offer {offer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -52,6 +52,7 @@
</thead> </thead>
<tbody class="divide-y divide-brand-bg-light"> <tbody class="divide-y divide-brand-bg-light">
{% for key in keys %} {% for key in keys %}
{% set key_offers = offers_by_key.get(key.id | string, []) %}
<tr class="hover:bg-brand-bg/30"> <tr class="hover:bg-brand-bg/30">
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="font-mono text-white font-medium">{{ key.key }}</div> <div class="font-mono text-white font-medium">{{ key.key }}</div>
@@ -67,44 +68,42 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if key.used %} {% if key.used %}
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm"> <span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">Verwendet</span>
Verwendet
</span>
{% else %} {% else %}
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm"> <span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">Verfügbar</span>
Verfügbar
</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-400"> <td class="px-6 py-4 text-sm text-gray-400">
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }} {{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
{% if key_offers %}
<button onclick="toggleOffers('{{ key.id }}')"
class="ml-2 px-2 py-0.5 bg-brand-highlight/20 text-brand-highlight rounded text-xs font-medium hover:bg-brand-highlight/30 transition-colors">
📄 {{ key_offers | length }} Angebot{{ 'e' if key_offers | length != 1 else '' }}
</button>
{% endif %}
</td> </td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')" <button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')"
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors" class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors" title="Bearbeiten">
title="Bearbeiten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg> </svg>
</button> </button>
<button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')" <button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')"
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors" class="text-green-400 hover:text-green-300 p-2 rounded transition-colors" title="Angebot in MOCO erstellen">
title="Angebot senden">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
</button> </button>
{% if not key.used %} {% if not key.used %}
<button onclick="copyKey('{{ key.key }}')" <button onclick="copyKey('{{ key.key }}')"
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors" class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors" title="Kopieren">
title="Kopieren">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg> </svg>
</button> </button>
<button onclick="deleteKey('{{ key.id }}')" <button onclick="deleteKey('{{ key.id }}')"
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors" class="text-red-400 hover:text-red-300 p-2 rounded transition-colors" title="Löschen">
title="Löschen">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
@@ -112,6 +111,41 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if key_offers %}
<tr id="offers-{{ key.id }}" class="hidden bg-brand-bg/20">
<td colspan="5" class="px-6 py-3">
<div class="space-y-2">
{% for offer in key_offers %}
<div class="flex items-center justify-between bg-brand-bg rounded-lg px-4 py-2.5 border border-brand-bg-light">
<div class="flex items-center gap-4 text-sm">
<a href="{{ offer.moco_offer_url }}" target="_blank"
class="font-medium text-white hover:text-brand-highlight transition-colors">
{{ offer.moco_offer_identifier or offer.offer_title or 'Angebot' }}
</a>
<span class="text-gray-400">{{ offer.company_name }}</span>
<span class="text-gray-400">
{% if offer.price %}{{ "%.2f"|format(offer.price) }} €{% endif %}
{% if offer.payment_frequency %} / {{ offer.payment_frequency }}{% endif %}
</span>
<span class="text-gray-500 text-xs">{{ offer.created_at.strftime('%d.%m.%Y') if offer.created_at else '' }}</span>
</div>
<div class="flex items-center gap-2">
{% if offer.status == 'sent' %}
<span class="px-2 py-0.5 bg-green-600/30 text-green-400 rounded text-xs">Versendet</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 rounded text-xs">Entwurf</span>
<button onclick="openSendModal('{{ offer.id }}', '{{ offer.moco_offer_identifier or offer.offer_title }}', '{{ offer.company_name }}')"
class="px-3 py-1 bg-brand-highlight text-brand-bg rounded text-xs font-semibold hover:bg-brand-highlight-dark transition-colors">
Versenden
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -166,13 +200,14 @@
<!-- Offer Modal --> <!-- Offer Modal -->
<div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4"> <div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-1">Angebot senden</h2> <h2 class="text-2xl font-bold text-white mb-1">Angebot in MOCO erstellen</h2>
<p class="text-gray-400 text-sm mb-6">Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.</p> <p class="text-gray-400 text-sm mb-6">Erstellt ein Angebot direkt in MOCO. Versand erfolgt manuell in MOCO.</p>
<form id="offerForm" class="space-y-4"> <form id="offerForm" class="space-y-4">
<input type="hidden" id="offer_key_id" name="key_id"> <input type="hidden" id="offer_key_id" name="key_id">
<input type="hidden" id="offer_company_name_hidden" name="company_name">
<!-- Calculated Info --> <!-- Plan Info -->
<div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600"> <div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600">
<div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div> <div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div>
<div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div> <div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div>
@@ -181,6 +216,16 @@
<div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div> <div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div>
</div> </div>
<!-- Company Dropdown -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Firma (MOCO)</label>
<select id="offer_company_select" name="company_id" required
onchange="onCompanySelect(this)"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
<option value="">Wird geladen…</option>
</select>
</div>
<!-- Price --> <!-- Price -->
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label> <label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
@@ -213,20 +258,13 @@
Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span> Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span>
</div> </div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail-Adresse</label>
<input type="email" id="offer_email" name="email" placeholder="kunde@beispiel.de" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
</div>
<div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div> <div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<button type="submit" id="offerSubmitBtn" <button type="submit" id="offerSubmitBtn"
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2"> class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Angebot senden Angebot in MOCO erstellen
</button> </button>
<button type="button" onclick="closeOfferModal()" <button type="button" onclick="closeOfferModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"> class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
@@ -237,6 +275,33 @@
</div> </div>
</div> </div>
<!-- Send Offer Confirmation Modal -->
<div id="sendModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-1">Angebot versenden</h2>
<p class="text-gray-400 text-sm mb-6">Das Angebot wird per E-Mail an die hinterlegten Empfänger in MOCO gesendet.</p>
<div id="sendOfferInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600 mb-6">
<div class="flex justify-between"><span class="text-gray-400">Angebot:</span><span id="send_offer_title" class="text-white font-medium"></span></div>
<div class="flex justify-between"><span class="text-gray-400">Firma:</span><span id="send_offer_company" class="text-white"></span></div>
</div>
<div id="send-status" class="hidden text-sm rounded-lg px-4 py-3 mb-4"></div>
<div class="flex gap-3">
<button id="sendConfirmBtn" onclick="confirmSendOffer()"
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Jetzt versenden
</button>
<button type="button" onclick="closeSendModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
<!-- Generate Modal --> <!-- Generate Modal -->
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4"> <div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
@@ -374,7 +439,7 @@ async function deleteKey(keyId) {
} }
} }
// ── Offer Modal ──────────────────────────────────────────────────────────── // ── Offer Modal (MOCO) ─────────────────────────────────────────────────────
const AVG_TOKENS_PER_POST = 50000; const AVG_TOKENS_PER_POST = 50000;
const API_COST_PER_1K_EUR = 0.003; const API_COST_PER_1K_EUR = 0.003;
const SERVER_SHARE_EUR = 1.60; const SERVER_SHARE_EUR = 1.60;
@@ -384,12 +449,10 @@ function calcSuggestedPrice(dailyTokenLimit) {
const monthlyTokens = dailyTokenLimit * 30; const monthlyTokens = dailyTokenLimit * 30;
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR; const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5; const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5;
return Math.ceil(raw / 5) * 5; // round up to nearest 5€ return Math.ceil(raw / 5) * 5;
} }
function fmtNum(n) { function fmtNum(n) { return n.toLocaleString('de-DE'); }
return n.toLocaleString('de-DE');
}
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) { function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_key_id').value = keyId; document.getElementById('offer_key_id').value = keyId;
@@ -409,8 +472,13 @@ function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_suggested').textContent = ''; document.getElementById('offer_suggested').textContent = '';
} }
// Reset company select
const sel = document.getElementById('offer_company_select');
sel.innerHTML = '<option value="">Wird geladen…</option>';
document.getElementById('offer_company_name_hidden').value = '';
loadCompanies();
selectFreq('monatlich'); selectFreq('monatlich');
document.getElementById('offer_email').value = '';
document.getElementById('offer-status').classList.add('hidden'); document.getElementById('offer-status').classList.add('hidden');
document.getElementById('offerModal').classList.remove('hidden'); document.getElementById('offerModal').classList.remove('hidden');
} }
@@ -423,10 +491,8 @@ function closeOfferModal() {
function selectFreq(freq) { function selectFreq(freq) {
document.getElementById('offer_payment_frequency').value = freq; document.getElementById('offer_payment_frequency').value = freq;
['monatlich', 'jährlich', 'einmalig'].forEach(f => { ['monatlich', 'jährlich', 'einmalig'].forEach(f => {
const btn = document.getElementById('freq-' + f); document.getElementById('freq-' + f).classList.toggle('active-freq', f === freq);
btn.classList.toggle('active-freq', f === freq);
}); });
// Show yearly total hint
const price = parseFloat(document.getElementById('offer_price').value) || 0; const price = parseFloat(document.getElementById('offer_price').value) || 0;
const hint = document.getElementById('yearly-hint'); const hint = document.getElementById('yearly-hint');
if (freq === 'jährlich' && price > 0) { if (freq === 'jährlich' && price > 0) {
@@ -438,43 +504,141 @@ function selectFreq(freq) {
} }
document.getElementById('offer_price').addEventListener('input', () => { document.getElementById('offer_price').addEventListener('input', () => {
if (document.getElementById('offer_payment_frequency').value === 'jährlich') { if (document.getElementById('offer_payment_frequency').value === 'jährlich') selectFreq('jährlich');
selectFreq('jährlich');
}
}); });
// ── Company select ─────────────────────────────────────────────────────────
async function loadCompanies() {
const sel = document.getElementById('offer_company_select');
try {
const resp = await fetch('/admin/api/moco/companies');
if (!resp.ok) throw new Error('Fehler beim Laden');
const companies = await resp.json();
sel.innerHTML = '<option value="">Firma auswählen…</option>';
companies.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
} catch (e) {
sel.innerHTML = '<option value="">Fehler beim Laden der Firmen</option>';
console.error('Company load error', e);
}
}
function onCompanySelect(sel) {
const name = sel.options[sel.selectedIndex]?.text || '';
document.getElementById('offer_company_name_hidden').value = sel.value ? name : '';
}
document.getElementById('offerForm').addEventListener('submit', async (e) => { document.getElementById('offerForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target); const formData = new FormData(e.target);
const keyId = formData.get('key_id'); const keyId = formData.get('key_id');
const companyId = formData.get('company_id');
const statusEl = document.getElementById('offer-status'); const statusEl = document.getElementById('offer-status');
const submitBtn = document.getElementById('offerSubmitBtn'); const submitBtn = document.getElementById('offerSubmitBtn');
if (!companyId) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Bitte eine Firma auswählen.';
statusEl.classList.remove('hidden');
return;
}
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…'; submitBtn.textContent = 'Wird erstellt…';
statusEl.classList.add('hidden'); statusEl.classList.add('hidden');
try { try {
const response = await fetch(`/admin/api/license-keys/${keyId}/send-offer`, { const response = await fetch(`/admin/api/license-keys/${keyId}/create-offer`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Fehler beim Erstellen');
if (!response.ok) throw new Error(data.detail || 'Fehler beim Senden');
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300'; statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
statusEl.textContent = data.message || 'Angebot erfolgreich gesendet!'; statusEl.innerHTML = 'Angebot erfolgreich erstellt! '
+ (data.offer_url ? `<a href="${data.offer_url}" target="_blank" class="underline font-semibold text-green-300 hover:text-white">In MOCO öffnen →</a>` : '');
statusEl.classList.remove('hidden'); statusEl.classList.remove('hidden');
submitBtn.textContent = '✓ Gesendet'; submitBtn.textContent = '✓ Erstellt';
// Reset button after 4 seconds so another offer can be created
setTimeout(() => {
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
location.reload();
}, 3000);
} catch (error) { } catch (error) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300'; statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Fehler: ' + error.message; statusEl.textContent = 'Fehler: ' + error.message;
statusEl.classList.remove('hidden'); statusEl.classList.remove('hidden');
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Angebot senden'; submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
} }
}); });
// ── Offer sub-rows toggle ───────────────────────────────────────────────────
function toggleOffers(keyId) {
const row = document.getElementById('offers-' + keyId);
if (!row) return;
row.classList.toggle('hidden');
}
// ── Send Offer Modal ────────────────────────────────────────────────────────
let currentSendOfferId = null;
function openSendModal(offerId, title, company) {
currentSendOfferId = offerId;
document.getElementById('send_offer_title').textContent = title || 'Angebot';
document.getElementById('send_offer_company').textContent = company || '';
const statusEl = document.getElementById('send-status');
statusEl.classList.add('hidden');
statusEl.textContent = '';
const btn = document.getElementById('sendConfirmBtn');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Jetzt versenden';
document.getElementById('sendModal').classList.remove('hidden');
}
function closeSendModal() {
document.getElementById('sendModal').classList.add('hidden');
currentSendOfferId = null;
}
async function confirmSendOffer() {
if (!currentSendOfferId) return;
const statusEl = document.getElementById('send-status');
const btn = document.getElementById('sendConfirmBtn');
btn.disabled = true;
btn.textContent = 'Wird versendet…';
statusEl.classList.add('hidden');
try {
const response = await fetch(`/admin/api/license-key-offers/${currentSendOfferId}/send`, {
method: 'POST'
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Fehler beim Versenden');
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
statusEl.textContent = 'Angebot erfolgreich versendet!';
statusEl.classList.remove('hidden');
btn.textContent = '✓ Versendet';
setTimeout(() => {
closeSendModal();
location.reload();
}, 2000);
} catch (error) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Fehler: ' + error.message;
statusEl.classList.remove('hidden');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Erneut versuchen';
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -705,8 +705,8 @@
<div id="editView" class="hidden"> <div id="editView" class="hidden">
<textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea> <textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea>
<div class="flex items-center justify-between mt-4"> <div class="flex items-center justify-between mt-4">
<span class="text-sm text-gray-400"> <span class="text-sm" id="charCountLabel">
<span id="charCount">{{ post.post_content | length }}</span> Zeichen <span id="charCount">{{ post.post_content | length }}</span> / 3000 Zeichen
</span> </span>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick="cancelEdit()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors"> <button onclick="cancelEdit()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors">
@@ -1137,7 +1137,16 @@ function setPreviewMode(mode) {
function updateCharCount() { function updateCharCount() {
const textarea = document.getElementById('editTextarea'); const textarea = document.getElementById('editTextarea');
const charCount = document.getElementById('charCount'); const charCount = document.getElementById('charCount');
charCount.textContent = textarea.value.length; const label = document.getElementById('charCountLabel');
const len = textarea.value.length;
charCount.textContent = len;
if (len > 3000) {
label.style.color = '#ef4444';
} else if (len > 2700) {
label.style.color = '#f59e0b';
} else {
label.style.color = '#9ca3af';
}
} }
function cancelEdit() { function cancelEdit() {
@@ -1154,6 +1163,11 @@ async function saveEdit() {
return; return;
} }
if (newContent.length > 3000) {
showToast('Post überschreitet das LinkedIn-Limit von 3000 Zeichen.', 'error');
return;
}
// Show loading state // Show loading state
const originalBtnHTML = saveBtn.innerHTML; const originalBtnHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner"></div>'; saveBtn.innerHTML = '<div class="loading-spinner"></div>';
@@ -1979,10 +1993,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize media upload (multi-media support) // Initialize media upload (multi-media support)
initMediaUpload(); initMediaUpload();
// Add event listener for textarea character count // Add event listener for textarea character count + initialize color
const textarea = document.getElementById('editTextarea'); const textarea = document.getElementById('editTextarea');
if (textarea) { if (textarea) {
textarea.addEventListener('input', updateCharCount); textarea.addEventListener('input', updateCharCount);
updateCharCount();
} }
// Allow Enter key to apply custom suggestion // Allow Enter key to apply custom suggestion

View File

@@ -29,7 +29,7 @@ from src.services.email_service import (
send_approval_request_email, send_approval_request_email,
send_decision_notification_email, send_decision_notification_email,
validate_token, validate_token,
mark_token_used mark_token_used,
) )
from src.services.background_jobs import ( from src.services.background_jobs import (
job_manager, JobType, JobStatus, job_manager, JobType, JobStatus,
@@ -1241,36 +1241,41 @@ async def dashboard(request: Request):
if session.company_id: if session.company_id:
try: try:
company = await db.get_company(UUID(session.company_id)) company_id_uuid = UUID(session.company_id)
employees_raw = await db.get_company_employees(UUID(session.company_id)) company, employees_raw, pending_invitations, quota, license_key = await asyncio.gather(
pending_invitations = await db.get_pending_invitations(UUID(session.company_id)) db.get_company(company_id_uuid),
quota = await db.get_company_daily_quota(UUID(session.company_id)) db.get_company_employees(company_id_uuid),
license_key = await db.get_company_limits(UUID(session.company_id)) 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: except Exception as company_error:
logger.warning(f"Could not load company data for {session.company_id}: {company_error}") logger.warning(f"Could not load company data for {session.company_id}: {company_error}")
# Continue without company data - better than crashing # Continue without company data - better than crashing
# Add avatar URLs to employees # Add avatar URLs to employees (parallel)
employees = [] def _make_emp_session(emp):
for emp in employees_raw: return UserSession(
emp_session = UserSession(
user_id=str(emp.id), user_id=str(emp.id),
linkedin_picture=emp.linkedin_picture, linkedin_picture=emp.linkedin_picture,
email=emp.email, email=emp.email,
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type, account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
display_name=emp.display_name display_name=emp.display_name
) )
avatar_url = await get_user_avatar(emp_session, emp.id)
# Create employee dict with avatar emp_sessions = [_make_emp_session(emp) for emp in employees_raw]
emp_dict = { avatar_urls = await asyncio.gather(*[get_user_avatar(s, emp.id) for s, emp in zip(emp_sessions, employees_raw)])
employees = [
{
"id": emp.id, "id": emp.id,
"email": emp.email, "email": emp.email,
"display_name": emp.display_name or emp.linkedin_name or emp.email, "display_name": emp.display_name or emp.linkedin_name or emp.email,
"onboarding_status": emp.onboarding_status, "onboarding_status": emp.onboarding_status,
"avatar_url": avatar_url "avatar_url": avatar_url
} }
employees.append(emp_dict) for emp, avatar_url in zip(employees_raw, avatar_urls)
]
user_id = UUID(session.user_id) user_id = UUID(session.user_id)
profile_picture = await get_user_avatar(session, user_id) profile_picture = await get_user_avatar(session, user_id)
@@ -1586,14 +1591,13 @@ async def post_detail_page(request: Request, post_id: str):
if str(post.user_id) != session.user_id: if str(post.user_id) != session.user_id:
return RedirectResponse(url="/posts", status_code=302) return RedirectResponse(url="/posts", status_code=302)
profile = await db.get_profile(post.user_id) profile, linkedin_posts, profile_picture_url, profile_analysis_record = await asyncio.gather(
linkedin_posts = await db.get_linkedin_posts(post.user_id) 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] reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
# Get avatar with priority: LinkedInAccount > profile_picture > session.linkedin_picture
profile_picture_url = await get_user_avatar(session, post.user_id)
profile_analysis_record = await db.get_profile_analysis(post.user_id)
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
post_type = None post_type = None
@@ -2198,7 +2202,7 @@ async def update_post_status(
if status == "approved" and profile and profile.customer_email: if status == "approved" and profile and profile.customer_email:
# Build base URL from request # Build base URL from request
base_url = str(request.base_url).rstrip('/') base_url = str(request.base_url).rstrip('/')
email_sent = send_approval_request_email( email_sent = await send_approval_request_email(
to_email=profile.customer_email, to_email=profile.customer_email,
post_id=UUID(post_id), post_id=UUID(post_id),
post_title=post.topic_title or "Untitled Post", post_title=post.topic_title or "Untitled Post",
@@ -2235,6 +2239,9 @@ async def update_post(
if str(post.user_id) != session.user_id: if str(post.user_id) != session.user_id:
raise HTTPException(status_code=403, detail="Not 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 # Save as new version
writer_versions = post.writer_versions or [] writer_versions = post.writer_versions or []
writer_versions.append(content) writer_versions.append(content)
@@ -2265,7 +2272,7 @@ async def update_post(
@user_router.get("/api/email-action/{token}", response_class=HTMLResponse) @user_router.get("/api/email-action/{token}", response_class=HTMLResponse)
async def handle_email_action(request: Request, token: str): async def handle_email_action(request: Request, token: str):
"""Handle email action (approve/reject) from email link.""" """Handle email action (approve/reject) from email link."""
token_data = validate_token(token) token_data = await validate_token(token)
if not token_data: if not token_data:
return templates.TemplateResponse("email_action_result.html", { return templates.TemplateResponse("email_action_result.html", {
@@ -2306,7 +2313,7 @@ async def handle_email_action(request: Request, token: str):
await db.update_generated_post(post_id, {"status": new_status}) await db.update_generated_post(post_id, {"status": new_status})
# Mark token as used # Mark token as used
mark_token_used(token) await mark_token_used(token)
# Send notification to creator # Send notification to creator
if profile and profile.creator_email: if profile and profile.creator_email:
@@ -2713,27 +2720,30 @@ async def company_manage_page(request: Request, employee_id: str = None):
all_employees = await db.get_company_employees(company_id) all_employees = await db.get_company_employees(company_id)
active_employees = [emp for emp in all_employees if emp.onboarding_status == "completed"] active_employees = [emp for emp in all_employees if emp.onboarding_status == "completed"]
# Build display info for employees with correct avatar URLs # 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 # Note: emp is a User object from get_company_employees which has linkedin_name and linkedin_picture
active_employees_info = [] def _make_emp_session_manage(emp):
for emp in active_employees: return UserSession(
# Create minimal session for employee to get avatar
emp_session = UserSession(
user_id=str(emp.id), user_id=str(emp.id),
linkedin_picture=emp.linkedin_picture, linkedin_picture=emp.linkedin_picture,
email=emp.email, email=emp.email,
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type, account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
display_name=emp.display_name display_name=emp.display_name
) )
avatar_url = await get_user_avatar(emp_session, emp.id)
active_employees_info.append({ 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), "id": str(emp.id),
"email": emp.email, "email": emp.email,
"display_name": emp.linkedin_name or emp.display_name or emp.email, "display_name": emp.linkedin_name or emp.display_name or emp.email,
"linkedin_picture": avatar_url, # Now contains the correct avatar with priority "linkedin_picture": avatar_url,
"onboarding_status": emp.onboarding_status "onboarding_status": emp.onboarding_status
}) }
for emp, avatar_url in zip(active_employees, emp_avatars)
]
# Selected employee data # Selected employee data
selected_employee = None selected_employee = None
@@ -2749,10 +2759,11 @@ async def company_manage_page(request: Request, employee_id: str = None):
break break
if selected_employee: if selected_employee:
# Get employee's user_id emp_profile, employee_posts = await asyncio.gather(
emp_profile = await db.get_profile(UUID(employee_id)) db.get_profile(UUID(employee_id)),
db.get_generated_posts(UUID(employee_id)),
)
if emp_profile: if emp_profile:
employee_posts = await db.get_generated_posts(emp_profile.id)
pending_posts = len([p for p in employee_posts if p.status in ['draft', 'pending']]) 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']]) approved_posts = len([p for p in employee_posts if p.status in ['approved', 'published']])
@@ -2827,24 +2838,25 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
if not employee_id: if not employee_id:
return RedirectResponse(url="/company/manage", status_code=302) return RedirectResponse(url="/company/manage", status_code=302)
# Get employee info # Get employee info + post in parallel
emp_profile = await db.get_profile(UUID(employee_id)) emp_profile, emp_user, post = await asyncio.gather(
db.get_profile(UUID(employee_id)),
db.get_user(UUID(employee_id)),
db.get_generated_post(UUID(post_id)),
)
if not emp_profile: if not emp_profile:
return RedirectResponse(url="/company/manage", status_code=302) return RedirectResponse(url="/company/manage", status_code=302)
# Verify employee belongs to this company # 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: if not emp_user or str(emp_user.company_id) != session.company_id:
return RedirectResponse(url="/company/manage", status_code=302) return RedirectResponse(url="/company/manage", status_code=302)
post = await db.get_generated_post(UUID(post_id))
if not post or str(post.user_id) != str(emp_profile.id): 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) return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302)
profile = await db.get_profile(emp_profile.id) profile = emp_profile # same object - no second DB call needed
# Get employee's avatar # Get employee's avatar
# Note: Create minimal session for the employee to use get_user_avatar
emp_session = UserSession( emp_session = UserSession(
user_id=str(emp_profile.id), user_id=str(emp_profile.id),
linkedin_picture=emp_user.linkedin_picture, linkedin_picture=emp_user.linkedin_picture,