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

@@ -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,