Improved Licensing

This commit is contained in:
2026-02-18 00:00:32 +01:00
parent a062383af0
commit af2c9e7fd8
17 changed files with 831 additions and 250 deletions

View File

@@ -29,6 +29,9 @@ textstat>=0.7.12
scikit-learn==1.5.2
setuptools>=65.0.0
# PDF Generation
reportlab>=4.0.0
# Web Frontend
fastapi==0.115.0
uvicorn==0.32.0

View File

@@ -75,6 +75,14 @@ class BaseAgent(ABC):
except Exception as e:
logger.warning(f"Failed to log usage to DB: {e}")
# Increment company token quota
if self._company_id:
try:
from uuid import UUID
await db.increment_company_tokens(UUID(self._company_id), total_tokens)
except Exception as e:
logger.warning(f"Failed to increment company tokens: {e}")
async def call_openai(
self,
system_prompt: str,

View File

@@ -1184,18 +1184,17 @@ class DatabaseClient:
self,
key: str,
max_employees: int,
max_posts_per_day: int,
max_researches_per_day: int,
daily_token_limit: Optional[int] = None,
description: Optional[str] = None
) -> LicenseKey:
"""Create new license key."""
data = {
"key": key,
"max_employees": max_employees,
"max_posts_per_day": max_posts_per_day,
"max_researches_per_day": max_researches_per_day,
"description": description,
}
if daily_token_limit is not None:
data["daily_token_limit"] = daily_token_limit
result = await asyncio.to_thread(
lambda: self.client.table("license_keys").insert(data).execute()
)
@@ -1222,6 +1221,16 @@ class DatabaseClient:
logger.info(f"Marked license key as used: {key}")
return LicenseKey(**result.data[0])
async def get_license_key_by_id(self, key_id: UUID) -> Optional[LicenseKey]:
"""Get license key by UUID."""
result = await asyncio.to_thread(
lambda: self.client.table("license_keys")
.select("*")
.eq("id", str(key_id))
.execute()
)
return LicenseKey(**result.data[0]) if result.data else None
async def update_license_key(self, key_id: UUID, updates: Dict[str, Any]) -> LicenseKey:
"""Update license key limits (admin only)."""
result = await asyncio.to_thread(
@@ -1270,8 +1279,7 @@ class DatabaseClient:
data = {
"company_id": str(company_id),
"date": date_.isoformat(),
"posts_created": 0,
"researches_created": 0
"tokens_used": 0
}
result = await asyncio.to_thread(
lambda: self.client.table("company_daily_quotas")
@@ -1280,47 +1288,22 @@ class DatabaseClient:
)
return CompanyDailyQuota(**result.data[0])
async def increment_company_posts_quota(self, company_id: UUID) -> None:
"""Increment daily posts count for company."""
async def increment_company_tokens(self, company_id: UUID, tokens: int) -> None:
"""Increment daily token usage for company."""
try:
quota = await self.get_company_daily_quota(company_id)
new_count = quota.posts_created + 1
new_count = quota.tokens_used + tokens
result = await asyncio.to_thread(
await asyncio.to_thread(
lambda: self.client.table("company_daily_quotas")
.update({"posts_created": new_count})
.update({"tokens_used": new_count})
.eq("id", str(quota.id))
.execute()
)
logger.info(f"Incremented posts quota for company {company_id}: {quota.posts_created} -> {new_count}")
if not result.data:
logger.error(f"Failed to increment posts quota - no data returned")
logger.info(f"Incremented token quota for company {company_id}: {quota.tokens_used} -> {new_count}")
except Exception as e:
logger.error(f"Error incrementing posts quota for company {company_id}: {e}")
raise
async def increment_company_researches_quota(self, company_id: UUID) -> None:
"""Increment daily researches count for company."""
try:
quota = await self.get_company_daily_quota(company_id)
new_count = quota.researches_created + 1
result = await asyncio.to_thread(
lambda: self.client.table("company_daily_quotas")
.update({"researches_created": new_count})
.eq("id", str(quota.id))
.execute()
)
logger.info(f"Incremented researches quota for company {company_id}: {quota.researches_created} -> {new_count}")
if not result.data:
logger.error(f"Failed to increment researches quota - no data returned")
except Exception as e:
logger.error(f"Error incrementing researches quota for company {company_id}: {e}")
raise
logger.warning(f"Error incrementing token quota for company {company_id}: {e}")
async def get_company_limits(self, company_id: UUID) -> Optional[LicenseKey]:
"""Get company limits from associated license key.
@@ -1340,39 +1323,21 @@ class DatabaseClient:
return LicenseKey(**result.data[0]) if result.data else None
async def check_company_post_limit(self, company_id: UUID) -> tuple[bool, str]:
"""Check if company can create more posts today.
async def check_company_token_limit(self, company_id: UUID) -> tuple[bool, str, int, int]:
"""Check if company has token budget remaining today.
Returns (can_create: bool, error_message: str)
Returns (can_proceed: bool, error_message: str, tokens_used: int, daily_limit: int)
"""
license_key = await self.get_company_limits(company_id)
if not license_key:
# No license key, use defaults (unlimited)
return True, ""
if not license_key or license_key.daily_token_limit is None:
return True, "", 0, 0 # no limit = unlimited
quota = await self.get_company_daily_quota(company_id)
if quota.posts_created >= license_key.max_posts_per_day:
return False, f"Tageslimit erreicht ({license_key.max_posts_per_day} Posts/Tag). Versuche es morgen wieder."
if quota.tokens_used >= license_key.daily_token_limit:
return False, f"Tageslimit erreicht ({license_key.daily_token_limit:,} Tokens/Tag). Morgen wieder verfügbar.", quota.tokens_used, license_key.daily_token_limit
return True, ""
async def check_company_research_limit(self, company_id: UUID) -> tuple[bool, str]:
"""Check if company can create more researches today.
Returns (can_create: bool, error_message: str)
"""
license_key = await self.get_company_limits(company_id)
if not license_key:
# No license key, use defaults (unlimited)
return True, ""
quota = await self.get_company_daily_quota(company_id)
if quota.researches_created >= license_key.max_researches_per_day:
return False, f"Tageslimit erreicht ({license_key.max_researches_per_day} Researches/Tag). Versuche es morgen wieder."
return True, ""
return True, "", quota.tokens_used, license_key.daily_token_limit
async def check_company_employee_limit(self, company_id: UUID) -> tuple[bool, str]:
"""Check if company can add more employees.

View File

@@ -365,8 +365,7 @@ class LicenseKey(DBModel):
# Limits
max_employees: int = 5
max_posts_per_day: int = 10
max_researches_per_day: int = 5
daily_token_limit: Optional[int] = None # None = unlimited
# Usage
used: bool = False
@@ -382,7 +381,6 @@ class CompanyDailyQuota(DBModel):
id: Optional[UUID] = None
company_id: UUID
date: date
posts_created: int = 0
researches_created: int = 0
tokens_used: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -3,6 +3,8 @@ import smtplib
import secrets
from email.mime.text import MIMEText
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 uuid import UUID
@@ -75,6 +77,48 @@ def send_email(to_email: str, subject: str, html_content: str) -> bool:
return False
def send_email_with_attachment(
to_email: str,
subject: str,
html_content: str,
attachment_bytes: bytes,
attachment_filename: str,
) -> bool:
"""Send an email with a file attachment (e.g. PDF)."""
if not settings.smtp_host or not settings.smtp_user:
logger.warning("SMTP not configured, skipping email send")
return False
try:
msg = MIMEMultipart("mixed")
msg["Subject"] = subject
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
msg["To"] = to_email
# HTML body
alt_part = MIMEMultipart("alternative")
alt_part.attach(MIMEText(html_content, "html"))
msg.attach(alt_part)
# Attachment
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment_bytes)
encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{attachment_filename}"')
msg.attach(part)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_user, settings.smtp_password)
server.sendmail(settings.smtp_user, to_email, msg.as_string())
logger.info(f"Email with attachment sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email with attachment to {to_email}: {e}")
return False
def send_approval_request_email(
to_email: str,
post_id: UUID,

View File

@@ -1,7 +1,9 @@
"""Admin panel routes (password-protected) - User Management & Statistics."""
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
@@ -12,11 +14,19 @@ 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
# Router with /admin prefix
admin_router = APIRouter(prefix="/admin", tags=["admin"])
@@ -549,8 +559,7 @@ async def license_keys_page(request: Request):
async def generate_license_key(
request: Request,
max_employees: int = Form(...),
max_posts_per_day: int = Form(...),
max_researches_per_day: int = Form(...),
daily_token_limit: Optional[int] = Form(None),
description: str = Form(None)
):
"""Generate new license key."""
@@ -567,8 +576,7 @@ async def generate_license_key(
license_key = await db.create_license_key(
key=key,
max_employees=max_employees,
max_posts_per_day=max_posts_per_day,
max_researches_per_day=max_researches_per_day,
daily_token_limit=daily_token_limit,
description=description
)
@@ -583,8 +591,7 @@ async def update_license_key_route(
request: Request,
key_id: str,
max_employees: int = Form(...),
max_posts_per_day: int = Form(...),
max_researches_per_day: int = Form(...),
daily_token_limit: Optional[int] = Form(None),
description: str = Form(None)
):
"""Update license key limits."""
@@ -594,8 +601,7 @@ async def update_license_key_route(
try:
updates = {
"max_employees": max_employees,
"max_posts_per_day": max_posts_per_day,
"max_researches_per_day": max_researches_per_day,
"daily_token_limit": daily_token_limit,
"description": description
}
@@ -618,3 +624,333 @@ async def delete_license_key_route(request: Request, key_id: str):
except Exception as e:
logger.error(f"Error deleting license key: {e}")
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
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", ".")
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(
request: Request,
key_id: str,
email: str = Form(...),
price: float = Form(...),
payment_frequency: str = Form(...),
):
"""Generate a PDF offer and send it via email."""
if not verify_auth(request):
raise HTTPException(status_code=401)
try:
key = await db.get_license_key_by_id(UUID(key_id))
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,
price=price,
payment_frequency=payment_frequency,
recipient_email=email,
)
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"
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,
)
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."})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending license offer: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -62,8 +62,7 @@
<td class="px-6 py-4">
<div class="text-sm text-gray-300 space-y-1">
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
<div>📝 {{ key.max_posts_per_day }} Posts/Tag</div>
<div>🔍 {{ key.max_researches_per_day }} Researches/Tag</div>
<div>🪙 {% if key.daily_token_limit %}{{ key.daily_token_limit | int | string }} Tokens/Tag{% else %}Unbegrenzt{% endif %}</div>
</div>
</td>
<td class="px-6 py-4">
@@ -81,13 +80,20 @@
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
</td>
<td class="px-6 py-4 text-right">
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.max_posts_per_day }}, {{ key.max_researches_per_day }}, '{{ 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"
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">
<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"/>
</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"
@@ -129,18 +135,11 @@
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
Tägliches Token-Limit
</label>
<input type="number" id="edit_max_posts_per_day" name="max_posts_per_day" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" id="edit_max_researches_per_day" name="max_researches_per_day" min="1" required
<input type="number" id="edit_daily_token_limit" name="daily_token_limit" min="1000" placeholder="z.B. 100000"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
<p class="text-xs text-gray-500 mt-1">Leer lassen = unbegrenzt</p>
</div>
<div>
@@ -164,6 +163,80 @@
</div>
</div>
<!-- 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>
<form id="offerForm" class="space-y-4">
<input type="hidden" id="offer_key_id" name="key_id">
<!-- Calculated 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>
<div class="flex justify-between"><span class="text-gray-400">Token-Limit/Tag:</span><span id="offer_tokens" class="text-white"></span></div>
<div class="flex justify-between"><span class="text-gray-400">Ca. Posts/Monat:</span><span id="offer_posts" class="text-white"></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>
<!-- Price -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
<input type="number" id="offer_price" name="price" step="0.01" min="0" 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>
<!-- Payment frequency -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Zahlungsweise</label>
<div class="grid grid-cols-3 gap-2" id="freq-buttons">
<button type="button" onclick="selectFreq('monatlich')" id="freq-monatlich"
class="freq-btn active-freq px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
Monatlich
</button>
<button type="button" onclick="selectFreq('jährlich')" id="freq-jährlich"
class="freq-btn px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
Jährlich
</button>
<button type="button" onclick="selectFreq('einmalig')" id="freq-einmalig"
class="freq-btn px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors">
Einmalig
</button>
</div>
<input type="hidden" id="offer_payment_frequency" name="payment_frequency" value="monatlich">
</div>
<!-- Yearly total hint -->
<div id="yearly-hint" class="hidden text-sm text-gray-400 bg-brand-bg/40 rounded p-3 border border-gray-700">
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
</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">
Abbrechen
</button>
</div>
</form>
</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">
@@ -180,18 +253,11 @@
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
Tägliches Token-Limit
</label>
<input type="number" name="max_posts_per_day" min="1" value="10" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" name="max_researches_per_day" min="1" value="5" required
<input type="number" name="daily_token_limit" min="1000" value="100000"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
<p class="text-xs text-gray-500 mt-1">Leer lassen = unbegrenzt</p>
</div>
<div>
@@ -216,6 +282,14 @@
</div>
{% endblock %}
{% block head %}
<style>
.freq-btn { background-color: #3d4848; border-color: #5a6868; color: #9ca3af; }
.freq-btn:hover { background-color: #4a5858; color: #e5e7eb; }
.freq-btn.active-freq { background-color: #ffc700; border-color: #ffc700; color: #2d3838; font-weight: 700; }
</style>
{% endblock %}
{% block scripts %}
<script>
function openGenerateModal() {
@@ -227,11 +301,10 @@ function closeGenerateModal() {
document.getElementById('generateForm').reset();
}
function editKey(keyId, maxEmployees, maxPostsPerDay, maxResearchesPerDay, description) {
function editKey(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('edit_key_id').value = keyId;
document.getElementById('edit_max_employees').value = maxEmployees;
document.getElementById('edit_max_posts_per_day').value = maxPostsPerDay;
document.getElementById('edit_max_researches_per_day').value = maxResearchesPerDay;
document.getElementById('edit_daily_token_limit').value = dailyTokenLimit || '';
document.getElementById('edit_description').value = description;
document.getElementById('editModal').classList.remove('hidden');
}
@@ -300,5 +373,108 @@ async function deleteKey(keyId) {
alert('Fehler beim Löschen: ' + error.message);
}
}
// ── Offer Modal ────────────────────────────────────────────────────────────
const AVG_TOKENS_PER_POST = 50000;
const API_COST_PER_1K_EUR = 0.003;
const SERVER_SHARE_EUR = 1.60;
function calcSuggestedPrice(dailyTokenLimit) {
if (!dailyTokenLimit) return null;
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€
}
function fmtNum(n) {
return n.toLocaleString('de-DE');
}
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_key_id').value = keyId;
document.getElementById('offer_plan').textContent = description || 'Standard Plan';
document.getElementById('offer_employees').textContent = maxEmployees + ' Nutzer';
if (dailyTokenLimit) {
const postsPerMonth = Math.floor(dailyTokenLimit * 30 / AVG_TOKENS_PER_POST);
document.getElementById('offer_tokens').textContent = fmtNum(dailyTokenLimit) + ' Tokens/Tag';
document.getElementById('offer_posts').textContent = '~' + postsPerMonth + ' Posts';
const suggested = calcSuggestedPrice(dailyTokenLimit);
document.getElementById('offer_suggested').textContent = suggested + ' €';
document.getElementById('offer_price').value = suggested;
} else {
document.getElementById('offer_tokens').textContent = 'Unbegrenzt';
document.getElementById('offer_posts').textContent = '';
document.getElementById('offer_suggested').textContent = '';
}
selectFreq('monatlich');
document.getElementById('offer_email').value = '';
document.getElementById('offer-status').classList.add('hidden');
document.getElementById('offerModal').classList.remove('hidden');
}
function closeOfferModal() {
document.getElementById('offerModal').classList.add('hidden');
document.getElementById('offerForm').reset();
}
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);
});
// 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) {
document.getElementById('yearly-total').textContent = (price * 12).toLocaleString('de-DE', {minimumFractionDigits: 2}) + ' €';
hint.classList.remove('hidden');
} else {
hint.classList.add('hidden');
}
}
document.getElementById('offer_price').addEventListener('input', () => {
if (document.getElementById('offer_payment_frequency').value === 'jährlich') {
selectFreq('jährlich');
}
});
document.getElementById('offerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const keyId = formData.get('key_id');
const statusEl = document.getElementById('offer-status');
const submitBtn = document.getElementById('offerSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
statusEl.classList.add('hidden');
try {
const response = await fetch(`/admin/api/license-keys/${keyId}/send-offer`, {
method: 'POST',
body: formData
});
const data = await response.json();
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.textContent = data.message || 'Angebot erfolgreich gesendet!';
statusEl.classList.remove('hidden');
submitBtn.textContent = '✓ Gesendet';
} 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';
}
});
</script>
{% endblock %}

View File

@@ -78,9 +78,17 @@
{% block head %}{% endblock %}
</head>
<body class="text-gray-100 min-h-screen flex">
<body class="text-gray-100 min-h-screen flex {% if limit_reached %}pt-6{% endif %}">
{% if limit_reached %}
<!-- Token Limit Banner -->
<div class="fixed top-0 left-0 right-0 z-[9999] bg-red-600 text-white text-xs font-medium flex items-center justify-center gap-2 px-4" style="height: 1.5rem;">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
Token-Limit erreicht keine KI-Aktionen mehr heute möglich. Morgen wird das Limit zurückgesetzt.
</div>
{% endif %}
<!-- Sidebar -->
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full top-0" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
<div class="p-4 border-b border-gray-600">
<div class="flex items-center justify-between gap-3 logo-container">
<div class="logo-full">

View File

@@ -4,9 +4,9 @@
{% block content %}
<!-- Posts Sidebar -->
<div class="posts-sidebar">
<div class="posts-sidebar" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
<div class="posts-sidebar-header">
<button onclick="startNewPost()">
<button id="new-post-btn" onclick="startNewPost()" {% if limit_reached %}disabled title="{{ limit_message }}"{% endif %}>
+ Neuer Post
</button>
</div>
@@ -123,6 +123,12 @@ aside.collapsed ~ main .posts-sidebar {
background-color: #e6b300;
}
.posts-sidebar-header button:disabled {
background-color: #4a5568;
color: #a0aec0;
cursor: not-allowed;
}
/* Posts List */
.posts-sidebar-list {
flex: 1;
@@ -226,7 +232,7 @@ aside.collapsed ~ main .chat-fixed-input {
</style>
<!-- Header (Fixed at top) -->
<div class="chat-fixed-header fixed top-0 bg-brand-bg z-20">
<div class="chat-fixed-header fixed bg-brand-bg z-20" style="top: {% if limit_reached %}1.5rem{% else %}0{% endif %}">
<div class="px-8 py-4">
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
</div>
@@ -277,15 +283,16 @@ aside.collapsed ~ main .chat-fixed-input {
<input
type="text"
id="chat-input"
placeholder="Beschreibe deinen Post..."
class="w-full input-bg border border-gray-600 rounded-full px-6 py-3 text-white focus:outline-none focus:border-brand-highlight transition-colors"
onkeydown="handleChatKeydown(event)">
placeholder="{% if limit_reached %}Token-Limit erreicht morgen wieder verfügbar{% else %}Beschreibe deinen Post...{% endif %}"
class="w-full input-bg border border-gray-600 rounded-full px-6 py-3 text-white focus:outline-none focus:border-brand-highlight transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}"
onkeydown="handleChatKeydown(event)"
{% if limit_reached %}disabled{% endif %}>
</div>
<button
onclick="sendMessage()"
id="send-btn"
class="w-12 h-12 bg-brand-highlight hover:bg-yellow-500 text-black rounded-full transition-all flex items-center justify-center flex-shrink-0 hover:scale-110"
title="Senden (Enter)">
class="w-12 h-12 bg-brand-highlight hover:bg-yellow-500 text-black rounded-full transition-all flex items-center justify-center flex-shrink-0 hover:scale-110 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}"
{% if limit_reached %}disabled title="{{ limit_message }}"{% else %}title="Senden (Enter)"{% endif %}>
<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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
@@ -310,6 +317,28 @@ let currentPost = null;
let conversationId = null;
let selectedPostTypeId = null;
let currentLoadedPostId = null; // Track active post
let tokenLimitReached = {{ 'true' if limit_reached else 'false' }};
function disableChat(message) {
tokenLimitReached = true;
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const newPostBtn = document.getElementById('new-post-btn');
if (input) {
input.disabled = true;
input.placeholder = message || 'Token-Limit erreicht morgen wieder verfügbar';
input.classList.add('opacity-50', 'cursor-not-allowed');
}
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add('opacity-50', 'cursor-not-allowed');
sendBtn.title = message || 'Token-Limit erreicht';
}
if (newPostBtn) {
newPostBtn.disabled = true;
newPostBtn.title = message || 'Token-Limit erreicht';
}
}
// User profile data
const userProfilePicture = "{{ profile_picture or '' }}";
@@ -348,6 +377,8 @@ function handleChatKeydown(event) {
}
async function sendMessage() {
if (tokenLimitReached) return;
const input = document.getElementById('chat-input');
const message = input.value.trim();
@@ -436,6 +467,12 @@ async function sendMessage() {
saveBtn.classList.remove('hidden');
saveBtn.classList.add('flex');
}
} else if (result.token_limit_exceeded) {
disableChat(result.error);
// Remove temp message, show save option if there's an unsaved new post
if (!currentLoadedPostId && currentPost) {
showTokenLimitModal();
}
} else {
showToast('Fehler: ' + (result.error || 'Unbekannter Fehler'), 'error');
addMessageToChat('ai', '❌ Entschuldigung, es gab einen Fehler. Bitte versuche es erneut.');
@@ -599,6 +636,8 @@ function escapeHtml(text) {
}
function startNewPost() {
if (tokenLimitReached) return;
// Reset state
chatHistory = [];
currentPost = null;
@@ -824,6 +863,40 @@ function showToast(message, type = 'info') {
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Token limit modal (for new posts when limit is reached mid-session)
function showTokenLimitModal() {
document.getElementById('tokenLimitModal').classList.remove('hidden');
}
function closeLimitModal() {
document.getElementById('tokenLimitModal').classList.add('hidden');
}
async function saveAndCloseLimitModal() {
closeLimitModal();
if (currentPost) {
// Trigger save
const saveBtn = document.getElementById('save-btn');
if (saveBtn && !saveBtn.classList.contains('hidden')) {
saveBtn.click();
} else {
showToast('Bitte speichere deinen Post manuell.', 'error');
}
}
}
</script>
<!-- Token Limit Modal -->
<div id="tokenLimitModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3">Token-Limit erreicht</h3>
<p class="text-gray-400 text-sm mb-4">Das tägliche Token-Limit wurde erreicht. Möchtest du deinen bisherigen Post speichern?</p>
<div class="flex gap-3">
<button onclick="saveAndCloseLimitModal()" class="flex-1 btn-primary px-4 py-2 rounded-lg">Post speichern</button>
<button onclick="closeLimitModal()" class="px-4 py-2 bg-gray-600 rounded-lg text-white">Verwerfen</button>
</div>
</div>
</div>
{% endblock %}

View File

@@ -79,9 +79,18 @@
{% block head %}{% endblock %}
</head>
<body class="text-gray-100 min-h-screen flex">
<body class="text-gray-100 min-h-screen flex {% if limit_reached %}pt-6{% endif %}">
{% if limit_reached %}
<!-- Token Limit Banner -->
<div class="fixed top-0 left-0 right-0 z-[9999] bg-red-600 text-white text-xs font-medium flex items-center justify-center gap-2 px-4" style="height: 1.5rem;">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
Token-Limit erreicht keine KI-Aktionen mehr heute möglich. Morgen wird das Limit zurückgesetzt.
</div>
{% endif %}
<!-- Sidebar -->
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
<aside id="sidebar" class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full top-0" {% if limit_reached %}style="top: 1.5rem; height: calc(100vh - 1.5rem);"{% endif %}>
<div class="p-4 border-b border-gray-600">
<div class="flex items-center justify-between gap-3 logo-container">
<div class="logo-full">

View File

@@ -80,46 +80,35 @@
</div>
<!-- Daily Quota Section -->
{% if quota and license_key %}
{% if quota %}
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Tägliche Limits</h2>
<h2 class="text-lg font-semibold text-white mb-4">Token-Verbrauch heute</h2>
<div class="grid md:grid-cols-2 gap-4">
<!-- Posts Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Posts heute</span>
<span class="text-xs text-gray-500">{{ quota.posts_created | default(0) }}/{{ license_key.max_posts_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set posts_pct = ((quota.posts_created / license_key.max_posts_per_day * 100) if license_key.max_posts_per_day > 0 else 0) | round %}
<div class="bg-brand-highlight h-2 rounded-full transition-all"
style="width: {{ posts_pct }}%"></div>
</div>
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Tokens heute</span>
<span class="text-xs text-gray-500">
{{ quota.tokens_used | default(0) }}
/
{% if license_key and license_key.daily_token_limit %}{{ license_key.daily_token_limit }}{% else %}∞{% endif %}
</span>
</div>
<!-- Researches Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Researches heute</span>
<span class="text-xs text-gray-500">{{ quota.researches_created | default(0) }}/{{ license_key.max_researches_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set researches_pct = ((quota.researches_created / license_key.max_researches_per_day * 100) if license_key.max_researches_per_day > 0 else 0) | round %}
<div class="bg-blue-500 h-2 rounded-full transition-all"
style="width: {{ researches_pct }}%"></div>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% if license_key and license_key.daily_token_limit and license_key.daily_token_limit > 0 %}
{% set token_pct = ((quota.tokens_used / license_key.daily_token_limit * 100) | round) %}
{% set token_pct = [token_pct, 100] | min %}
{% else %}
{% set token_pct = 0 %}
{% endif %}
<div class="h-2 rounded-full transition-all {% if token_pct >= 90 %}bg-red-500{% elif token_pct >= 70 %}bg-yellow-500{% else %}bg-brand-highlight{% endif %}"
style="width: {{ token_pct }}%"></div>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">
Limits werden täglich um Mitternacht zurückgesetzt. (Lizenz: {{ license_key.key }})
Limits werden täglich um Mitternacht zurückgesetzt.
{% if license_key %}(Lizenz: {{ license_key.key }}){% else %}Unbegrenzter Account.{% endif %}
</p>
</div>
{% elif quota %}
<!-- No license key - show info message -->
<div class="bg-blue-900/50 border border-blue-500 text-blue-200 px-4 py-3 rounded-lg mb-6">
<strong>Info:</strong> Dieser Account hat keinen Lizenzschlüssel und ist daher unbegrenzt.
</div>
{% endif %}
<!-- Quick Actions -->

View File

@@ -13,23 +13,6 @@
</nav>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Wizard Container (hidden during generation) -->
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
<div class="max-w-2xl mx-auto">

View File

@@ -18,20 +18,6 @@
<p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form -->

View File

@@ -2,23 +2,6 @@
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
{% block content %}
<!-- Limit Warning -->
{% if limit_reached %}
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Wizard Container (hidden during generation) -->
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
<div class="max-w-2xl mx-auto">

View File

@@ -730,7 +730,7 @@
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
KI-Verbesserungen
</h3>
<button onclick="loadSuggestions()" id="refreshSuggestionsBtn" class="p-1.5 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors" title="KI-Vorschläge generieren">
<button onclick="loadSuggestions()" id="refreshSuggestionsBtn" {% if limit_reached %}disabled{% endif %} class="p-1.5 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" title="{% if limit_reached %}{{ limit_message }}{% else %}KI-Vorschläge generieren{% endif %}">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
@@ -738,19 +738,19 @@
<!-- Quick Suggestions (Default) -->
<div id="quickSuggestions" class="space-y-2 mb-4">
<p class="text-xs text-gray-500 mb-3">Schnelle Anpassungen:</p>
<button onclick="applyQuickSuggestion('Mache den Hook emotionaler und aufmerksamkeitsstärker')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Mache den Hook emotionaler und aufmerksamkeitsstärker')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
<span class="text-brand-highlight"></span> Hook verstärken
</button>
<button onclick="applyQuickSuggestion('Füge einen starken Call-to-Action am Ende hinzu')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Füge einen starken Call-to-Action am Ende hinzu')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
<span class="text-brand-highlight">🎯</span> Call-to-Action hinzufügen
</button>
<button onclick="applyQuickSuggestion('Füge eine kurze persönliche Anekdote oder Erfahrung hinzu')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Füge eine kurze persönliche Anekdote oder Erfahrung hinzu')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
<span class="text-brand-highlight">📖</span> Storytelling einbauen
</button>
<button onclick="applyQuickSuggestion('Verbessere die Struktur: Kürzere Absätze, mehr Weißraum, bessere Lesbarkeit')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Verbessere die Struktur: Kürzere Absätze, mehr Weißraum, bessere Lesbarkeit')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
<span class="text-brand-highlight">📝</span> Struktur optimieren
</button>
<button onclick="applyQuickSuggestion('Kürze den Post auf das Wesentliche, entferne überflüssige Worte')" class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2">
<button {% if not limit_reached %}onclick="applyQuickSuggestion('Kürze den Post auf das Wesentliche, entferne überflüssige Worte')"{% endif %} {% if limit_reached %}disabled{% endif %} class="w-full text-left px-3 py-2 bg-brand-bg/50 hover:bg-brand-bg rounded-lg text-sm text-gray-300 hover:text-white transition-colors flex items-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
<span class="text-brand-highlight">✂️</span> Kürzer & prägnanter
</button>
</div>
@@ -768,8 +768,8 @@
<div class="mt-4 pt-4 border-t border-brand-bg-light">
<label class="text-xs text-gray-400 block mb-2">Eigene Anweisung</label>
<div class="flex gap-2">
<input type="text" id="customSuggestion" placeholder="z.B. Mache es humorvoller" class="flex-1 px-3 py-2 bg-brand-bg border border-brand-bg-light rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
<button onclick="applyCustomSuggestion()" id="applyCustomBtn" class="px-3 py-2 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark rounded-lg transition-colors">
<input type="text" id="customSuggestion" placeholder="z.B. Mache es humorvoller" class="flex-1 px-3 py-2 bg-brand-bg border border-brand-bg-light rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" {% if limit_reached %}disabled{% endif %}>
<button {% if not limit_reached %}onclick="applyCustomSuggestion()"{% endif %} id="applyCustomBtn" class="px-3 py-2 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark rounded-lg transition-colors {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}" {% if limit_reached %}disabled{% endif %}>
<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="M13 5l7 7-7 7M5 5l7 7-7 7"/></svg>
</button>
</div>

View File

@@ -7,20 +7,6 @@
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form -->

View File

@@ -1615,6 +1615,14 @@ async def post_detail_page(request: Request, post_id: str):
for item in post.media_items
]
# Check token limit for quick action buttons
limit_reached = False
limit_message = ""
if session.account_type in ("company", "employee") and session.company_id:
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_proceed
limit_message = error_msg
return templates.TemplateResponse("post_detail.html", {
"request": request,
"page": "posts",
@@ -1627,7 +1635,9 @@ async def post_detail_page(request: Request, post_id: str):
"post_type_analysis": post_type_analysis,
"final_feedback": final_feedback,
"profile_picture_url": profile_picture_url,
"media_items_dict": media_items_dict
"media_items_dict": media_items_dict,
"limit_reached": limit_reached,
"limit_message": limit_message
})
except Exception as e:
import traceback
@@ -1643,11 +1653,11 @@ async def research_page(request: Request):
if not session:
return RedirectResponse(url="/login", status_code=302)
# Check research limit for companies/employees
# Check token limit for companies/employees
limit_reached = False
limit_message = ""
if session.account_type in ("company", "employee") and session.company_id:
can_create, error_msg = await db.check_company_research_limit(UUID(session.company_id))
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_create
limit_message = error_msg
@@ -1672,11 +1682,11 @@ async def create_post_page(request: Request):
if not session:
return RedirectResponse(url="/login", status_code=302)
# Check post limit for companies/employees
# Check token limit for companies/employees
limit_reached = False
limit_message = ""
if session.account_type in ("company", "employee") and session.company_id:
can_create, error_msg = await db.check_company_post_limit(UUID(session.company_id))
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_create
limit_message = error_msg
@@ -1703,6 +1713,14 @@ async def chat_create_page(request: Request):
user_id = UUID(session.user_id)
# Check token limit for companies/employees
limit_reached = False
limit_message = ""
if session.account_type in ("company", "employee") and session.company_id:
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_create
limit_message = error_msg
# Get post types
post_types = await db.get_post_types(user_id)
if not post_types:
@@ -1724,7 +1742,9 @@ async def chat_create_page(request: Request):
"session": session,
"post_types": post_types,
"profile_picture": profile_picture,
"saved_posts": saved_posts
"saved_posts": saved_posts,
"limit_reached": limit_reached,
"limit_message": limit_message
})
@@ -1848,11 +1868,9 @@ async def start_research(request: Request, background_tasks: BackgroundTasks, po
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
# CHECK COMPANY RESEARCH LIMIT and capture company_id for quota tracking
quota_company_id = None
# CHECK COMPANY TOKEN LIMIT
if session.account_type in ("company", "employee") and session.company_id:
quota_company_id = UUID(session.company_id)
can_create, error_msg = await db.check_company_research_limit(quota_company_id)
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_create:
raise HTTPException(status_code=429, detail=error_msg)
@@ -1876,14 +1894,6 @@ async def start_research(request: Request, background_tasks: BackgroundTasks, po
post_type_id=UUID(post_type_id) if post_type_id else None
)
# INCREMENT COMPANY QUOTA after successful research
if quota_company_id:
try:
await db.increment_company_researches_quota(quota_company_id)
logger.info(f"Incremented research quota for company {quota_company_id}")
except Exception as quota_error:
logger.error(f"Failed to increment research quota: {quota_error}")
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
except Exception as e:
logger.exception(f"Research failed: {e}")
@@ -1994,11 +2004,9 @@ async def create_post(
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
# CHECK COMPANY POST LIMIT and capture company_id for quota tracking
quota_company_id = None
# CHECK COMPANY TOKEN LIMIT
if session.account_type in ("company", "employee") and session.company_id:
quota_company_id = UUID(session.company_id)
can_create, error_msg = await db.check_company_post_limit(quota_company_id)
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_create:
raise HTTPException(status_code=429, detail=error_msg)
@@ -2031,14 +2039,6 @@ async def create_post(
selected_hook=selected_hook
)
# INCREMENT COMPANY QUOTA after successful creation
if quota_company_id:
try:
await db.increment_company_posts_quota(quota_company_id)
logger.info(f"Incremented post quota for company {quota_company_id}")
except Exception as quota_error:
logger.error(f"Failed to increment post quota: {quota_error}")
progress_store[task_id] = {
"status": "completed", "message": "Post erstellt!", "progress": 100,
"result": {
@@ -2061,6 +2061,12 @@ async def get_post_suggestions(request: Request, post_id: str):
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check token limit for companies/employees
if session.account_type in ("company", "employee") and session.company_id:
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_proceed:
raise HTTPException(status_code=429, detail=error_msg)
try:
post = await db.get_generated_post(UUID(post_id))
if not post:
@@ -2100,6 +2106,12 @@ async def revise_post(
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check token limit for companies/employees
if session.account_type in ("company", "employee") and session.company_id:
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_proceed:
raise HTTPException(status_code=429, detail=error_msg)
try:
post = await db.get_generated_post(UUID(post_id))
if not post:
@@ -2886,11 +2898,11 @@ async def company_manage_research(request: Request, employee_id: str = None):
if not emp_user or str(emp_user.company_id) != session.company_id:
return RedirectResponse(url="/company/manage", status_code=302)
# Check research limit
# Check token limit
limit_reached = False
limit_message = ""
if session.company_id:
can_create, error_msg = await db.check_company_research_limit(UUID(session.company_id))
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_create
limit_message = error_msg
@@ -2933,11 +2945,11 @@ async def company_manage_create(request: Request, employee_id: str = None):
if not emp_user or str(emp_user.company_id) != session.company_id:
return RedirectResponse(url="/company/manage", status_code=302)
# Check post limit
# Check token limit
limit_reached = False
limit_message = ""
if session.company_id:
can_create, error_msg = await db.check_company_post_limit(UUID(session.company_id))
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
limit_reached = not can_create
limit_message = error_msg
@@ -3308,6 +3320,12 @@ async def chat_generate_post(request: Request):
if not post_type_id:
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
# Check token limit for companies/employees
if session.account_type in ("company", "employee") and session.company_id:
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_proceed:
return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg})
user_id = UUID(session.user_id)
# Get post type info
@@ -3341,6 +3359,11 @@ async def chat_generate_post(request: Request):
# Generate post using writer agent with user's content as primary focus
from src.agents.writer import WriterAgent
writer = WriterAgent()
writer.set_tracking_context(
operation='post_creation',
user_id=session.user_id,
company_id=session.company_id
)
# Create a topic structure from user's message
topic = {
@@ -3399,6 +3422,12 @@ async def chat_refine_post(request: Request):
if not post_type_id:
return JSONResponse({"success": False, "error": "Post-Typ erforderlich"})
# Check token limit for companies/employees
if session.account_type in ("company", "employee") and session.company_id:
can_proceed, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_proceed:
return JSONResponse({"success": False, "token_limit_exceeded": True, "error": error_msg})
user_id = UUID(session.user_id)
# Get post type info
@@ -3442,6 +3471,11 @@ async def chat_refine_post(request: Request):
# Refine post using writer with feedback
from src.agents.writer import WriterAgent
writer = WriterAgent()
writer.set_tracking_context(
operation='post_creation',
user_id=session.user_id,
company_id=session.company_id
)
topic = {
"title": "Chat refinement",