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

View File

@@ -61,6 +61,10 @@ class Settings(BaseSettings):
# Token Encryption
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(
env_file=".env",
env_file_encoding="utf-8",

View File

@@ -11,7 +11,7 @@ from src.database.models import (
LinkedInProfile, LinkedInPost, Topic,
ProfileAnalysis, ResearchResult, GeneratedPost, PostType,
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}")
# ==================== 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 ====================
async def get_company_daily_quota(
@@ -1356,6 +1409,66 @@ class DatabaseClient:
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
db = DatabaseClient()

View File

@@ -376,6 +376,21 @@ class LicenseKey(DBModel):
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):
"""Daily usage quota for a company."""
id: Optional[UUID] = None

View File

@@ -6,48 +6,49 @@ from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from datetime import datetime, timezone
from uuid import UUID
from loguru import logger
from src.config import settings
# In-memory token store (in production, use Redis or database)
_email_tokens: Dict[str, Dict[str, Any]] = {}
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
"""Generate a unique token for email action."""
async 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."""
from src.database.client import db
token = secrets.token_urlsafe(32)
_email_tokens[token] = {
"post_id": str(post_id),
"action": action,
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
"used": False
}
await db.create_email_token(token, post_id, action, expires_hours)
return token
def validate_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate and return token data if valid."""
if token not in _email_tokens:
async def validate_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate and return token data if valid (checks DB)."""
from src.database.client import db
token_data = await db.get_email_token(token)
if not token_data:
return None
token_data = _email_tokens[token]
if token_data["used"]:
if token_data.get("used"):
return None
if datetime.utcnow() > token_data["expires_at"]:
return None
expires_at_raw = token_data.get("expires_at")
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
def mark_token_used(token: str):
"""Mark a token as used."""
if token in _email_tokens:
_email_tokens[token]["used"] = True
async def mark_token_used(token: str) -> None:
"""Mark a token as used in the database."""
from src.database.client import db
await db.mark_email_token_used(token)
def send_email(to_email: str, subject: str, html_content: str) -> bool:
@@ -119,7 +120,7 @@ def send_email_with_attachment(
return False
def send_approval_request_email(
async def send_approval_request_email(
to_email: str,
post_id: UUID,
post_title: str,
@@ -128,8 +129,8 @@ def send_approval_request_email(
image_url: Optional[str] = None
) -> bool:
"""Send email to customer requesting approval of a post."""
approve_token = generate_token(post_id, "approve")
reject_token = generate_token(post_id, "reject")
approve_token = await generate_token(post_id, "approve")
reject_token = await generate_token(post_id, "reject")
approve_url = f"{base_url}/api/email-action/{approve_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):
"""Main scheduler loop."""
_tick = 0
while self._running:
try:
await self._process_due_posts()
except Exception as 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)
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):
"""Process all posts that are due for publishing."""
due_posts = await self.db.get_scheduled_posts_due()

View File

@@ -1,9 +1,9 @@
"""Admin panel routes (password-protected) - User Management & Statistics."""
import asyncio
import re
import secrets
import math
from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path
from typing import Optional
from uuid import UUID
@@ -14,19 +14,13 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
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.web.admin.auth import (
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
)
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.services import moco_service
# Router with /admin prefix
admin_router = APIRouter(prefix="/admin", tags=["admin"])
@@ -534,10 +528,20 @@ async def license_keys_page(request: Request):
try:
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", {
"request": request,
"page": "license_keys",
"keys": keys,
"offers_by_key": offers_by_key,
"total_keys": len(keys),
"used_keys": len([k for k in keys if 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))
# ==================== LICENSE KEY OFFER ====================
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
# ==================== LICENSE KEY OFFER (MOCO) ====================
def _fmt_eur(amount: float) -> str:
"""Format a float as German EUR string, e.g. 1.250,00 €"""
return f"{amount:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
@admin_router.get("/api/moco/companies")
async def search_moco_companies_endpoint(request: Request, term: str = ""):
"""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(
key_description: str,
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(
@admin_router.post("/api/license-keys/{key_id}/create-offer")
async def create_moco_offer_endpoint(
request: Request,
key_id: str,
email: str = Form(...),
company_id: int = Form(...),
company_name: str = Form(...),
price: float = 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):
raise HTTPException(status_code=401)
@@ -883,74 +665,66 @@ async def send_license_offer(
if not key:
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
pdf_bytes = _build_offer_pdf(
key_description=key.description or "",
max_employees=key.max_employees,
daily_token_limit=key.daily_token_limit,
plan_name = key.description or "Standard Plan"
offer = await moco_service.create_moco_offer(
company_id=company_id,
company_name=company_name,
price=price,
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 = ""
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"
offer_url = offer.get("web_url") or offer.get("url") or f"https://{settings.moco_domain}.mocoapp.com/offers/{offer.get('id', '')}"
price_str = f"{price:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
plan_name = key.description or "Standard Plan"
html_body = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
<div style="background: #2d3838; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffc700; margin: 0; font-size: 22px;">LinkedIn Content Automation</h1>
<p style="color: #cccccc; margin: 6px 0 0; font-size: 13px;">Ihr persönliches Angebot</p>
</div>
<div style="background: #f9f9f9; padding: 32px;">
<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,
# Persist offer in DB
await db.create_license_key_offer(
license_key_id=UUID(key_id),
moco_offer_id=offer["id"],
moco_offer_identifier=offer.get("identifier"),
moco_offer_url=offer_url,
offer_title=offer.get("title") or f"LinkedIn Content Automation {plan_name}",
company_name=company_name,
price=price,
payment_frequency=payment_frequency,
)
if not success:
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."})
return JSONResponse({"success": True, "offer_url": offer_url})
except HTTPException:
raise
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))
@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>
<tbody class="divide-y divide-brand-bg-light">
{% for key in keys %}
{% set key_offers = offers_by_key.get(key.id | string, []) %}
<tr class="hover:bg-brand-bg/30">
<td class="px-6 py-4">
<div class="font-mono text-white font-medium">{{ key.key }}</div>
@@ -67,44 +68,42 @@
</td>
<td class="px-6 py-4">
{% if key.used %}
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
Verwendet
</span>
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">Verwendet</span>
{% else %}
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
Verfügbar
</span>
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">Verfügbar</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm text-gray-400">
{{ 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 class="px-6 py-4 text-right">
<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"
title="Bearbeiten">
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors" title="Bearbeiten">
<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"/>
</svg>
</button>
<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"
title="Angebot senden">
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors" title="Angebot in MOCO erstellen">
<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>
</button>
{% if not key.used %}
<button onclick="copyKey('{{ key.key }}')"
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
title="Kopieren">
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors" title="Kopieren">
<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"/>
</svg>
</button>
<button onclick="deleteKey('{{ key.id }}')"
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
title="Löschen">
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors" title="Löschen">
<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"/>
</svg>
@@ -112,6 +111,41 @@
{% endif %}
</td>
</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 %}
</tbody>
</table>
@@ -166,13 +200,14 @@
<!-- Offer Modal -->
<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">
<h2 class="text-2xl font-bold text-white mb-1">Angebot senden</h2>
<p class="text-gray-400 text-sm mb-6">Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.</p>
<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 Angebot direkt in MOCO. Versand erfolgt manuell in MOCO.</p>
<form id="offerForm" class="space-y-4">
<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 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>
@@ -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>
<!-- 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 -->
<div>
<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>
</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 class="flex gap-3 pt-2">
<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">
<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
<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
</button>
<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">
@@ -237,6 +275,33 @@
</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 -->
<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">
@@ -374,7 +439,7 @@ async function deleteKey(keyId) {
}
}
// ── Offer Modal ────────────────────────────────────────────────────────────
// ── Offer Modal (MOCO) ─────────────────────────────────────────────────────
const AVG_TOKENS_PER_POST = 50000;
const API_COST_PER_1K_EUR = 0.003;
const SERVER_SHARE_EUR = 1.60;
@@ -384,12 +449,10 @@ function calcSuggestedPrice(dailyTokenLimit) {
const monthlyTokens = dailyTokenLimit * 30;
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
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) {
return n.toLocaleString('de-DE');
}
function fmtNum(n) { return n.toLocaleString('de-DE'); }
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_key_id').value = keyId;
@@ -409,8 +472,13 @@ function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
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');
document.getElementById('offer_email').value = '';
document.getElementById('offer-status').classList.add('hidden');
document.getElementById('offerModal').classList.remove('hidden');
}
@@ -423,10 +491,8 @@ function closeOfferModal() {
function selectFreq(freq) {
document.getElementById('offer_payment_frequency').value = freq;
['monatlich', 'jährlich', 'einmalig'].forEach(f => {
const btn = document.getElementById('freq-' + f);
btn.classList.toggle('active-freq', f === freq);
document.getElementById('freq-' + f).classList.toggle('active-freq', f === freq);
});
// Show yearly total hint
const price = parseFloat(document.getElementById('offer_price').value) || 0;
const hint = document.getElementById('yearly-hint');
if (freq === 'jährlich' && price > 0) {
@@ -438,43 +504,141 @@ function selectFreq(freq) {
}
document.getElementById('offer_price').addEventListener('input', () => {
if (document.getElementById('offer_payment_frequency').value === 'jährlich') {
selectFreq('jährlich');
}
if (document.getElementById('offer_payment_frequency').value === '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) => {
e.preventDefault();
const formData = new FormData(e.target);
const keyId = formData.get('key_id');
const companyId = formData.get('company_id');
const statusEl = document.getElementById('offer-status');
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.textContent = 'Wird gesendet…';
submitBtn.textContent = 'Wird erstellt…';
statusEl.classList.add('hidden');
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',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Fehler beim Senden');
if (!response.ok) throw new Error(data.detail || 'Fehler beim Erstellen');
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');
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) {
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');
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>
{% endblock %}

View File

@@ -705,8 +705,8 @@
<div id="editView" class="hidden">
<textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea>
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-gray-400">
<span id="charCount">{{ post.post_content | length }}</span> Zeichen
<span class="text-sm" id="charCountLabel">
<span id="charCount">{{ post.post_content | length }}</span> / 3000 Zeichen
</span>
<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">
@@ -1137,7 +1137,16 @@ function setPreviewMode(mode) {
function updateCharCount() {
const textarea = document.getElementById('editTextarea');
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() {
@@ -1154,6 +1163,11 @@ async function saveEdit() {
return;
}
if (newContent.length > 3000) {
showToast('Post überschreitet das LinkedIn-Limit von 3000 Zeichen.', 'error');
return;
}
// Show loading state
const originalBtnHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner"></div>';
@@ -1979,10 +1993,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize media upload (multi-media support)
initMediaUpload();
// Add event listener for textarea character count
// Add event listener for textarea character count + initialize color
const textarea = document.getElementById('editTextarea');
if (textarea) {
textarea.addEventListener('input', updateCharCount);
updateCharCount();
}
// Allow Enter key to apply custom suggestion

View File

@@ -29,7 +29,7 @@ from src.services.email_service import (
send_approval_request_email,
send_decision_notification_email,
validate_token,
mark_token_used
mark_token_used,
)
from src.services.background_jobs import (
job_manager, JobType, JobStatus,
@@ -1241,36 +1241,41 @@ async def dashboard(request: Request):
if session.company_id:
try:
company = await db.get_company(UUID(session.company_id))
employees_raw = await db.get_company_employees(UUID(session.company_id))
pending_invitations = await db.get_pending_invitations(UUID(session.company_id))
quota = await db.get_company_daily_quota(UUID(session.company_id))
license_key = await db.get_company_limits(UUID(session.company_id))
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
employees = []
for emp in employees_raw:
emp_session = UserSession(
# 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
)
avatar_url = await get_user_avatar(emp_session, emp.id)
# Create employee dict with avatar
emp_dict = {
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
}
employees.append(emp_dict)
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)
@@ -1586,14 +1591,13 @@ async def post_detail_page(request: Request, post_id: str):
if str(post.user_id) != session.user_id:
return RedirectResponse(url="/posts", status_code=302)
profile = await db.get_profile(post.user_id)
linkedin_posts = await db.get_linkedin_posts(post.user_id)
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]
# 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
post_type = None
@@ -2198,7 +2202,7 @@ async def update_post_status(
if status == "approved" and profile and profile.customer_email:
# Build base URL from request
base_url = str(request.base_url).rstrip('/')
email_sent = send_approval_request_email(
email_sent = await send_approval_request_email(
to_email=profile.customer_email,
post_id=UUID(post_id),
post_title=post.topic_title or "Untitled Post",
@@ -2235,6 +2239,9 @@ async def update_post(
if str(post.user_id) != session.user_id:
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)
@@ -2265,7 +2272,7 @@ async def update_post(
@user_router.get("/api/email-action/{token}", response_class=HTMLResponse)
async def handle_email_action(request: Request, token: str):
"""Handle email action (approve/reject) from email link."""
token_data = validate_token(token)
token_data = await validate_token(token)
if not token_data:
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})
# Mark token as used
mark_token_used(token)
await mark_token_used(token)
# Send notification to creator
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)
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
active_employees_info = []
for emp in active_employees:
# Create minimal session for employee to get avatar
emp_session = UserSession(
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
)
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),
"email": 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
})
}
for emp, avatar_url in zip(active_employees, emp_avatars)
]
# Selected employee data
selected_employee = None
@@ -2749,10 +2759,11 @@ async def company_manage_page(request: Request, employee_id: str = None):
break
if selected_employee:
# Get employee's user_id
emp_profile = await db.get_profile(UUID(employee_id))
emp_profile, employee_posts = await asyncio.gather(
db.get_profile(UUID(employee_id)),
db.get_generated_posts(UUID(employee_id)),
)
if emp_profile:
employee_posts = await db.get_generated_posts(emp_profile.id)
pending_posts = len([p for p in employee_posts if p.status in ['draft', 'pending']])
approved_posts = len([p for p in employee_posts if p.status in ['approved', 'published']])
@@ -2827,24 +2838,25 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
if not employee_id:
return RedirectResponse(url="/company/manage", status_code=302)
# Get employee info
emp_profile = await db.get_profile(UUID(employee_id))
# Get employee info + post in parallel
emp_profile, emp_user, post = await asyncio.gather(
db.get_profile(UUID(employee_id)),
db.get_user(UUID(employee_id)),
db.get_generated_post(UUID(post_id)),
)
if not emp_profile:
return RedirectResponse(url="/company/manage", status_code=302)
# Verify employee belongs to this company
emp_user = await db.get_user(UUID(employee_id))
if not emp_user or str(emp_user.company_id) != session.company_id:
return RedirectResponse(url="/company/manage", status_code=302)
post = await db.get_generated_post(UUID(post_id))
if not post or str(post.user_id) != str(emp_profile.id):
return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302)
profile = await db.get_profile(emp_profile.id)
profile = emp_profile # same object - no second DB call needed
# Get employee's avatar
# Note: Create minimal session for the employee to use get_user_avatar
emp_session = UserSession(
user_id=str(emp_profile.id),
linkedin_picture=emp_user.linkedin_picture,