Improved features, implemented moco integration
This commit is contained in:
BIN
Angebot.pdf
Normal file
BIN
Angebot.pdf
Normal file
Binary file not shown.
@@ -61,6 +61,10 @@ class Settings(BaseSettings):
|
|||||||
# Token Encryption
|
# Token Encryption
|
||||||
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
|
# MOCO Integration
|
||||||
|
moco_api_key: str = "" # Token für Authorization-Header
|
||||||
|
moco_domain: str = "" # Subdomain: {domain}.mocoapp.com
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from src.database.models import (
|
|||||||
LinkedInProfile, LinkedInPost, Topic,
|
LinkedInProfile, LinkedInPost, Topic,
|
||||||
ProfileAnalysis, ResearchResult, GeneratedPost, PostType,
|
ProfileAnalysis, ResearchResult, GeneratedPost, PostType,
|
||||||
User, Profile, Company, Invitation, ExamplePost, ReferenceProfile,
|
User, Profile, Company, Invitation, ExamplePost, ReferenceProfile,
|
||||||
ApiUsageLog, LicenseKey, CompanyDailyQuota
|
ApiUsageLog, LicenseKey, CompanyDailyQuota, LicenseKeyOffer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1252,6 +1252,59 @@ class DatabaseClient:
|
|||||||
)
|
)
|
||||||
logger.info(f"Deleted license key: {key_id}")
|
logger.info(f"Deleted license key: {key_id}")
|
||||||
|
|
||||||
|
# ==================== LICENSE KEY OFFERS ====================
|
||||||
|
|
||||||
|
async def create_license_key_offer(
|
||||||
|
self,
|
||||||
|
license_key_id: UUID,
|
||||||
|
moco_offer_id: int,
|
||||||
|
moco_offer_identifier: Optional[str],
|
||||||
|
moco_offer_url: Optional[str],
|
||||||
|
offer_title: Optional[str],
|
||||||
|
company_name: Optional[str],
|
||||||
|
price: Optional[float],
|
||||||
|
payment_frequency: Optional[str],
|
||||||
|
) -> "LicenseKeyOffer":
|
||||||
|
"""Save a MOCO offer linked to a license key."""
|
||||||
|
data = {
|
||||||
|
"license_key_id": str(license_key_id),
|
||||||
|
"moco_offer_id": moco_offer_id,
|
||||||
|
"moco_offer_identifier": moco_offer_identifier,
|
||||||
|
"moco_offer_url": moco_offer_url,
|
||||||
|
"offer_title": offer_title,
|
||||||
|
"company_name": company_name,
|
||||||
|
"price": price,
|
||||||
|
"payment_frequency": payment_frequency,
|
||||||
|
"status": "draft",
|
||||||
|
}
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("license_key_offers").insert(data).execute()
|
||||||
|
)
|
||||||
|
return LicenseKeyOffer(**result.data[0])
|
||||||
|
|
||||||
|
async def list_license_key_offers(self, license_key_id: UUID) -> list["LicenseKeyOffer"]:
|
||||||
|
"""List all MOCO offers for a license key."""
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("license_key_offers")
|
||||||
|
.select("*")
|
||||||
|
.eq("license_key_id", str(license_key_id))
|
||||||
|
.order("created_at", desc=True)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return [LicenseKeyOffer(**row) for row in result.data]
|
||||||
|
|
||||||
|
async def update_license_key_offer_status(
|
||||||
|
self, offer_id: UUID, status: str
|
||||||
|
) -> "LicenseKeyOffer":
|
||||||
|
"""Update the status of a stored offer."""
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("license_key_offers")
|
||||||
|
.update({"status": status})
|
||||||
|
.eq("id", str(offer_id))
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return LicenseKeyOffer(**result.data[0])
|
||||||
|
|
||||||
# ==================== COMPANY QUOTAS ====================
|
# ==================== COMPANY QUOTAS ====================
|
||||||
|
|
||||||
async def get_company_daily_quota(
|
async def get_company_daily_quota(
|
||||||
@@ -1356,6 +1409,66 @@ class DatabaseClient:
|
|||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
# ==================== EMAIL ACTION TOKENS ====================
|
||||||
|
|
||||||
|
async def create_email_token(self, token: str, post_id: UUID, action: str, expires_hours: int = 72) -> None:
|
||||||
|
"""Store an email action token in the database."""
|
||||||
|
from datetime import timedelta
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
||||||
|
data = {
|
||||||
|
"token": token,
|
||||||
|
"post_id": str(post_id),
|
||||||
|
"action": action,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"used": False,
|
||||||
|
}
|
||||||
|
await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("email_action_tokens").insert(data).execute()
|
||||||
|
)
|
||||||
|
logger.debug(f"Created email token for post {post_id} action={action}")
|
||||||
|
|
||||||
|
async def get_email_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve email token data; returns None if not found."""
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("email_action_tokens").select("*").eq("token", token).execute()
|
||||||
|
)
|
||||||
|
if not result.data:
|
||||||
|
return None
|
||||||
|
return result.data[0]
|
||||||
|
|
||||||
|
async def mark_email_token_used(self, token: str) -> None:
|
||||||
|
"""Mark an email token as used."""
|
||||||
|
await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("email_action_tokens").update({"used": True}).eq("token", token).execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cleanup_expired_email_tokens(self) -> None:
|
||||||
|
"""Delete expired email tokens from the database."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("email_action_tokens").delete().lt("expires_at", now).execute()
|
||||||
|
)
|
||||||
|
count = len(result.data) if result.data else 0
|
||||||
|
if count:
|
||||||
|
logger.info(f"Cleaned up {count} expired email tokens")
|
||||||
|
|
||||||
|
# ==================== LINKEDIN TOKEN REFRESH ====================
|
||||||
|
|
||||||
|
async def get_expiring_linkedin_accounts(self, within_days: int = 7) -> list:
|
||||||
|
"""Return active LinkedIn accounts whose tokens expire within within_days and have a refresh_token."""
|
||||||
|
from src.database.models import LinkedInAccount
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff = (datetime.now(timezone.utc) + timedelta(days=within_days)).isoformat()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: self.client.table("linkedin_accounts")
|
||||||
|
.select("*")
|
||||||
|
.eq("is_active", True)
|
||||||
|
.lt("token_expires_at", cutoff)
|
||||||
|
.not_.is_("refresh_token", "null")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return [LinkedInAccount(**row) for row in result.data]
|
||||||
|
|
||||||
|
|
||||||
# Global database client instance
|
# Global database client instance
|
||||||
db = DatabaseClient()
|
db = DatabaseClient()
|
||||||
|
|||||||
@@ -376,6 +376,21 @@ class LicenseKey(DBModel):
|
|||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseKeyOffer(DBModel):
|
||||||
|
"""MOCO offer created for a license key."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
license_key_id: UUID
|
||||||
|
moco_offer_id: int
|
||||||
|
moco_offer_identifier: Optional[str] = None
|
||||||
|
moco_offer_url: Optional[str] = None
|
||||||
|
offer_title: Optional[str] = None
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
payment_frequency: Optional[str] = None
|
||||||
|
status: str = "draft"
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class CompanyDailyQuota(DBModel):
|
class CompanyDailyQuota(DBModel):
|
||||||
"""Daily usage quota for a company."""
|
"""Daily usage quota for a company."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
|
|||||||
@@ -6,48 +6,49 @@ from email.mime.multipart import MIMEMultipart
|
|||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from email import encoders
|
from email import encoders
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
|
|
||||||
# In-memory token store (in production, use Redis or database)
|
|
||||||
_email_tokens: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
|
async def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
|
||||||
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
|
"""Generate a unique token for email action and persist it to the database."""
|
||||||
"""Generate a unique token for email action."""
|
from src.database.client import db
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
_email_tokens[token] = {
|
await db.create_email_token(token, post_id, action, expires_hours)
|
||||||
"post_id": str(post_id),
|
|
||||||
"action": action,
|
|
||||||
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
|
|
||||||
"used": False
|
|
||||||
}
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def validate_token(token: str) -> Optional[Dict[str, Any]]:
|
async def validate_token(token: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Validate and return token data if valid."""
|
"""Validate and return token data if valid (checks DB)."""
|
||||||
if token not in _email_tokens:
|
from src.database.client import db
|
||||||
|
token_data = await db.get_email_token(token)
|
||||||
|
if not token_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
token_data = _email_tokens[token]
|
if token_data.get("used"):
|
||||||
|
|
||||||
if token_data["used"]:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if datetime.utcnow() > token_data["expires_at"]:
|
expires_at_raw = token_data.get("expires_at")
|
||||||
|
if expires_at_raw:
|
||||||
|
if isinstance(expires_at_raw, str):
|
||||||
|
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
expires_at = expires_at_raw
|
||||||
|
if not expires_at.tzinfo:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) > expires_at:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return token_data
|
return token_data
|
||||||
|
|
||||||
|
|
||||||
def mark_token_used(token: str):
|
async def mark_token_used(token: str) -> None:
|
||||||
"""Mark a token as used."""
|
"""Mark a token as used in the database."""
|
||||||
if token in _email_tokens:
|
from src.database.client import db
|
||||||
_email_tokens[token]["used"] = True
|
await db.mark_email_token_used(token)
|
||||||
|
|
||||||
|
|
||||||
def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
||||||
@@ -119,7 +120,7 @@ def send_email_with_attachment(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_approval_request_email(
|
async def send_approval_request_email(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
post_id: UUID,
|
post_id: UUID,
|
||||||
post_title: str,
|
post_title: str,
|
||||||
@@ -128,8 +129,8 @@ def send_approval_request_email(
|
|||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send email to customer requesting approval of a post."""
|
"""Send email to customer requesting approval of a post."""
|
||||||
approve_token = generate_token(post_id, "approve")
|
approve_token = await generate_token(post_id, "approve")
|
||||||
reject_token = generate_token(post_id, "reject")
|
reject_token = await generate_token(post_id, "reject")
|
||||||
|
|
||||||
approve_url = f"{base_url}/api/email-action/{approve_token}"
|
approve_url = f"{base_url}/api/email-action/{approve_token}"
|
||||||
reject_url = f"{base_url}/api/email-action/{reject_token}"
|
reject_url = f"{base_url}/api/email-action/{reject_token}"
|
||||||
|
|||||||
272
src/services/moco_service.py
Normal file
272
src/services/moco_service.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""MOCO API integration for offer creation."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Token token={settings.moco_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
return f"https://{settings.moco_domain}.mocoapp.com/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
async def search_moco_companies(term: str) -> list[dict]:
|
||||||
|
"""Search MOCO companies by name term.
|
||||||
|
|
||||||
|
Returns a list of dicts with keys: id, name.
|
||||||
|
"""
|
||||||
|
if not settings.moco_api_key or not settings.moco_domain:
|
||||||
|
logger.warning("MOCO not configured (moco_api_key / moco_domain missing)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
params = {"type": "customer"}
|
||||||
|
if term:
|
||||||
|
params["term"] = term
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{_base_url()}/companies",
|
||||||
|
headers=_headers(),
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
companies = response.json()
|
||||||
|
return [{"id": c["id"], "name": c["name"]} for c in companies]
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"MOCO companies search failed ({e.response.status_code}): {e.response.text}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MOCO companies search error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_company_details(company_id: int) -> Optional[dict]:
|
||||||
|
"""Fetch a MOCO company by ID. Returns the company dict or None."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{_base_url()}/companies/{company_id}",
|
||||||
|
headers=_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch MOCO company {company_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_company_contact(company_id: int, company_name: str) -> Optional[dict]:
|
||||||
|
"""Return the first contact person associated with the given MOCO company.
|
||||||
|
|
||||||
|
MOCO's /contacts/people has no company_id filter, so we search by company
|
||||||
|
name and then filter client-side on company.id.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{_base_url()}/contacts/people",
|
||||||
|
headers=_headers(),
|
||||||
|
params={"term": company_name},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
people = response.json()
|
||||||
|
|
||||||
|
# Filter to contacts whose company.id matches exactly
|
||||||
|
matched = [
|
||||||
|
p for p in people
|
||||||
|
if (p.get("company") or {}).get("id") == company_id
|
||||||
|
]
|
||||||
|
contact = matched[0] if matched else None
|
||||||
|
if contact:
|
||||||
|
logger.debug(
|
||||||
|
f"MOCO contact for company {company_id}: "
|
||||||
|
f"gender={contact.get('gender')!r} lastname={contact.get('lastname')!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No contact found for MOCO company {company_id} ({company_name!r})")
|
||||||
|
return contact
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch MOCO contacts for company {company_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_salutation_html(contact: Optional[dict]) -> str:
|
||||||
|
"""Build an HTML salutation block from a MOCO contact dict."""
|
||||||
|
if not contact:
|
||||||
|
greeting = "Sehr geehrte Damen und Herren"
|
||||||
|
else:
|
||||||
|
gender = (contact.get("gender") or "").strip().upper()
|
||||||
|
lastname = (contact.get("lastname") or "").strip()
|
||||||
|
logger.debug(f"MOCO contact gender={gender!r} lastname={lastname!r}")
|
||||||
|
|
||||||
|
if gender == "M":
|
||||||
|
greeting = f"Sehr geehrter Herr {lastname}".strip()
|
||||||
|
elif gender == "F":
|
||||||
|
greeting = f"Sehr geehrte Frau {lastname}".strip()
|
||||||
|
else:
|
||||||
|
greeting = "Sehr geehrte Damen und Herren"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"<p>{greeting},</p>"
|
||||||
|
"<p>vielen Dank für Ihre Anfrage. Anbei finden Sie ein ausgearbeitetes Angebot "
|
||||||
|
"für die von Ihnen angefragten Leistungen.</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_footer_html() -> str:
|
||||||
|
"""Build the HTML footer using MOCO's native {customer_approval_link} variable.
|
||||||
|
|
||||||
|
MOCO replaces {customer_approval_link} with a properly formatted and
|
||||||
|
clickable link at render/send time. Using a manual <a> tag causes MOCO
|
||||||
|
to override the link text with its own '[identifier] Angebot' format.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
"<p>Wir freuen uns über Ihre Bestätigung per E-Mail oder direkt über diesen Link: "
|
||||||
|
"{customer_approval_link}</p>"
|
||||||
|
"<p>Freundliche Grüße,<br>"
|
||||||
|
"Olivia Kibele<br>"
|
||||||
|
"olivia.kibele@onyva.de<br>"
|
||||||
|
"0751 18523411</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_moco_offer(
|
||||||
|
company_id: int,
|
||||||
|
company_name: str,
|
||||||
|
price: float,
|
||||||
|
payment_frequency: str,
|
||||||
|
plan_name: str,
|
||||||
|
max_employees: int,
|
||||||
|
daily_token_limit: Optional[int],
|
||||||
|
) -> dict:
|
||||||
|
"""Create an offer in MOCO and return the full offer object."""
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
due_date = today + timedelta(days=30)
|
||||||
|
|
||||||
|
# Fetch company details for billing address + contact lookup
|
||||||
|
company = await _get_company_details(company_id)
|
||||||
|
recipient_address = (
|
||||||
|
(company.get("address") or "").strip() or company_name
|
||||||
|
if company else company_name
|
||||||
|
)
|
||||||
|
|
||||||
|
contact = await _get_company_contact(company_id, company_name)
|
||||||
|
salutation_html = _build_salutation_html(contact)
|
||||||
|
|
||||||
|
if daily_token_limit:
|
||||||
|
posts_per_month = int(daily_token_limit * 30 / 50_000)
|
||||||
|
token_detail = (
|
||||||
|
f"{daily_token_limit:,}".replace(",", ".") +
|
||||||
|
f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
token_detail = "Unbegrenzt"
|
||||||
|
|
||||||
|
freq_label = payment_frequency.capitalize()
|
||||||
|
offer_title = f"LinkedIn Content Automation – {plan_name}"
|
||||||
|
|
||||||
|
description_text = (
|
||||||
|
f"LinkedIn Content Automation – {plan_name}\n\n"
|
||||||
|
f"Lizenzdetails:\n"
|
||||||
|
f"• Mitarbeiter: {max_employees} Nutzer\n"
|
||||||
|
f"• KI-Token-Limit: {token_detail}\n"
|
||||||
|
f"• Zahlungsweise: {freq_label}\n\n"
|
||||||
|
f"Enthaltene Funktionen:\n"
|
||||||
|
f"• LinkedIn-Profil-Analyse (KI-gestützt)\n"
|
||||||
|
f"• Post-Typen & Strategie mit Gewichtung\n"
|
||||||
|
f"• KI-Post-Erstellung (chat-basiert, iterativ)\n"
|
||||||
|
f"• Mitarbeiterverwaltung & Freigabe-Workflows\n"
|
||||||
|
f"• Research-Funktion & Posting-Kalender\n"
|
||||||
|
f"• E-Mail-Benachrichtigungen"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"company_id": company_id,
|
||||||
|
"recipient_address": recipient_address,
|
||||||
|
"date": today.isoformat(),
|
||||||
|
"due_date": due_date.isoformat(),
|
||||||
|
"title": offer_title,
|
||||||
|
"tax": 19.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"discount": 0,
|
||||||
|
"salutation": salutation_html,
|
||||||
|
"footer": _build_footer_html(),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "description",
|
||||||
|
"title": "Leistungsbeschreibung",
|
||||||
|
"description": description_text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item",
|
||||||
|
"title": f"Lizenz – {plan_name}",
|
||||||
|
"description": f"{max_employees} Nutzer · {token_detail} · {freq_label}",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "Monat" if payment_frequency == "monatlich" else "Pauschal",
|
||||||
|
"unit_price": price,
|
||||||
|
"net_total": price,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{_base_url()}/offers",
|
||||||
|
headers=_headers(),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
offer = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"MOCO offer created: id={offer.get('id')}, identifier={offer.get('identifier')}"
|
||||||
|
)
|
||||||
|
return offer
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"MOCO offer creation failed ({e.response.status_code}): {e.response.text}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MOCO offer creation error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def send_moco_offer(moco_offer_id: int) -> None:
|
||||||
|
"""Send a MOCO offer to its default recipients via email.
|
||||||
|
|
||||||
|
Uses POST /offers/{id}/send_email with empty recipient lists so MOCO
|
||||||
|
falls back to the default recipients configured on the customer.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"subject": "Ihr Angebot – LinkedIn Content Automation",
|
||||||
|
"text": (
|
||||||
|
"Anbei finden Sie unser Angebot für die LinkedIn Content Automation Plattform.\n\n"
|
||||||
|
"Bitte bestätigen Sie direkt über den Link im Angebot.\n\n"
|
||||||
|
"Freundliche Grüße,\nOlivia Kibele"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{_base_url()}/offers/{moco_offer_id}/send_email",
|
||||||
|
headers=_headers(),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"MOCO offer {moco_offer_id} sent via email")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"MOCO send_email failed ({e.response.status_code}): {e.response.text}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MOCO send_email error: {e}")
|
||||||
|
raise
|
||||||
@@ -54,14 +54,44 @@ class SchedulerService:
|
|||||||
|
|
||||||
async def _run_loop(self):
|
async def _run_loop(self):
|
||||||
"""Main scheduler loop."""
|
"""Main scheduler loop."""
|
||||||
|
_tick = 0
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._process_due_posts()
|
await self._process_due_posts()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Scheduler error: {e}")
|
logger.error(f"Scheduler error: {e}")
|
||||||
|
|
||||||
|
# Daily jobs (every 1440 ticks = 24h at 60s interval)
|
||||||
|
if _tick % 1440 == 0:
|
||||||
|
try:
|
||||||
|
await self._refresh_expiring_tokens()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh job error: {e}")
|
||||||
|
try:
|
||||||
|
await self.db.cleanup_expired_email_tokens()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Email token cleanup error: {e}")
|
||||||
|
|
||||||
|
_tick += 1
|
||||||
await asyncio.sleep(self.check_interval)
|
await asyncio.sleep(self.check_interval)
|
||||||
|
|
||||||
|
async def _refresh_expiring_tokens(self):
|
||||||
|
"""Proactively refresh LinkedIn tokens expiring within 7 days."""
|
||||||
|
from src.services.linkedin_service import linkedin_service
|
||||||
|
accounts = await self.db.get_expiring_linkedin_accounts(within_days=7)
|
||||||
|
if not accounts:
|
||||||
|
return
|
||||||
|
logger.info(f"Refreshing {len(accounts)} expiring LinkedIn tokens")
|
||||||
|
for account in accounts:
|
||||||
|
try:
|
||||||
|
refreshed = await linkedin_service.refresh_access_token(account.id)
|
||||||
|
if refreshed:
|
||||||
|
logger.info(f"Token refresh ok: {account.id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Token refresh failed: {account.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh error {account.id}: {e}")
|
||||||
|
|
||||||
async def _process_due_posts(self):
|
async def _process_due_posts(self):
|
||||||
"""Process all posts that are due for publishing."""
|
"""Process all posts that are due for publishing."""
|
||||||
due_posts = await self.db.get_scheduled_posts_due()
|
due_posts = await self.db.get_scheduled_posts_due()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Admin panel routes (password-protected) - User Management & Statistics."""
|
"""Admin panel routes (password-protected) - User Management & Statistics."""
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -14,19 +14,13 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
|
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
||||||
from reportlab.lib.units import cm
|
|
||||||
from reportlab.lib import colors
|
|
||||||
from reportlab.lib.pagesizes import A4
|
|
||||||
|
|
||||||
from src.database import db
|
from src.database import db
|
||||||
from src.web.admin.auth import (
|
from src.web.admin.auth import (
|
||||||
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
||||||
)
|
)
|
||||||
from src.web.user.auth import UserSession, set_user_session
|
from src.web.user.auth import UserSession, set_user_session
|
||||||
from src.services.email_service import send_email_with_attachment
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
|
from src.services import moco_service
|
||||||
|
|
||||||
# Router with /admin prefix
|
# Router with /admin prefix
|
||||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -534,10 +528,20 @@ async def license_keys_page(request: Request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
keys = await db.list_license_keys()
|
keys = await db.list_license_keys()
|
||||||
|
# Load offers for all keys in parallel
|
||||||
|
offers_lists = await asyncio.gather(
|
||||||
|
*[db.list_license_key_offers(k.id) for k in keys],
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
offers_by_key = {
|
||||||
|
str(k.id): (v if not isinstance(v, Exception) else [])
|
||||||
|
for k, v in zip(keys, offers_lists)
|
||||||
|
}
|
||||||
return templates.TemplateResponse("license_keys.html", {
|
return templates.TemplateResponse("license_keys.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"page": "license_keys",
|
"page": "license_keys",
|
||||||
"keys": keys,
|
"keys": keys,
|
||||||
|
"offers_by_key": offers_by_key,
|
||||||
"total_keys": len(keys),
|
"total_keys": len(keys),
|
||||||
"used_keys": len([k for k in keys if k.used]),
|
"used_keys": len([k for k in keys if k.used]),
|
||||||
"available_keys": len([k for k in keys if not k.used])
|
"available_keys": len([k for k in keys if not k.used])
|
||||||
@@ -626,255 +630,33 @@ async def delete_license_key_route(request: Request, key_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ==================== LICENSE KEY OFFER ====================
|
# ==================== LICENSE KEY OFFER (MOCO) ====================
|
||||||
|
|
||||||
AVG_TOKENS_PER_POST = 50_000
|
|
||||||
SERVER_COST_EUR = 16.0
|
|
||||||
SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer
|
|
||||||
API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_eur(amount: float) -> str:
|
@admin_router.get("/api/moco/companies")
|
||||||
"""Format a float as German EUR string, e.g. 1.250,00 €"""
|
async def search_moco_companies_endpoint(request: Request, term: str = ""):
|
||||||
return f"{amount:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
|
"""Live-search MOCO companies by name."""
|
||||||
|
if not verify_auth(request):
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
companies = await moco_service.search_moco_companies(term)
|
||||||
|
return JSONResponse(companies)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MOCO company search error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
def _build_offer_pdf(
|
@admin_router.post("/api/license-keys/{key_id}/create-offer")
|
||||||
key_description: str,
|
async def create_moco_offer_endpoint(
|
||||||
max_employees: int,
|
|
||||||
daily_token_limit: Optional[int],
|
|
||||||
price: float,
|
|
||||||
payment_frequency: str,
|
|
||||||
recipient_email: str,
|
|
||||||
) -> bytes:
|
|
||||||
"""Generate a professional, simple PDF offer with logo."""
|
|
||||||
buffer = BytesIO()
|
|
||||||
|
|
||||||
PAGE_W, PAGE_H = A4
|
|
||||||
MARGIN = 1.8 * cm
|
|
||||||
|
|
||||||
doc = SimpleDocTemplate(
|
|
||||||
buffer,
|
|
||||||
pagesize=A4,
|
|
||||||
rightMargin=MARGIN,
|
|
||||||
leftMargin=MARGIN,
|
|
||||||
topMargin=MARGIN,
|
|
||||||
bottomMargin=MARGIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Colors ────────────────────────────────────────────────────────────────
|
|
||||||
C_DARK = colors.HexColor("#2d3838")
|
|
||||||
C_HIGHLIGHT = colors.HexColor("#ffc700")
|
|
||||||
C_GRAY = colors.HexColor("#666666")
|
|
||||||
C_LGRAY = colors.HexColor("#f5f5f5")
|
|
||||||
C_RULE = colors.HexColor("#e0e0e0")
|
|
||||||
C_WHITE = colors.white
|
|
||||||
CONTENT_W = PAGE_W - 2 * MARGIN
|
|
||||||
HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid
|
|
||||||
|
|
||||||
# ── Styles ────────────────────────────────────────────────────────────────
|
|
||||||
s = getSampleStyleSheet()
|
|
||||||
def ps(name, **kw):
|
|
||||||
return ParagraphStyle(name, parent=s["Normal"], **kw)
|
|
||||||
|
|
||||||
s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14)
|
|
||||||
s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12)
|
|
||||||
s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13)
|
|
||||||
s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold")
|
|
||||||
s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold",
|
|
||||||
spaceBefore=0, spaceAfter=0, letterSpacing=0.8)
|
|
||||||
s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold")
|
|
||||||
s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold")
|
|
||||||
s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11)
|
|
||||||
|
|
||||||
# ── Computed values ───────────────────────────────────────────────────────
|
|
||||||
today = datetime.utcnow().strftime("%d.%m.%Y")
|
|
||||||
validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y")
|
|
||||||
offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}"
|
|
||||||
plan_name = key_description or "Standard Plan"
|
|
||||||
price_str = _fmt_eur(price)
|
|
||||||
freq_cap = payment_frequency.capitalize()
|
|
||||||
|
|
||||||
if daily_token_limit:
|
|
||||||
posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST)
|
|
||||||
token_str = (
|
|
||||||
f"{daily_token_limit:,}".replace(",", ".") +
|
|
||||||
f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
token_str = "Unbegrenzt"
|
|
||||||
|
|
||||||
story = []
|
|
||||||
|
|
||||||
# ── Header: Logo + ANGEBOT ────────────────────────────────────────────────
|
|
||||||
logo_path = Path(__file__).parent.parent / "static" / "logo.png"
|
|
||||||
logo = (
|
|
||||||
Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional")
|
|
||||||
if logo_path.exists()
|
|
||||||
else Paragraph("<b>Onyva</b>", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold"))
|
|
||||||
)
|
|
||||||
hdr = Table(
|
|
||||||
[[
|
|
||||||
logo,
|
|
||||||
[
|
|
||||||
Paragraph('<font color="#ffc700"><b>ANGEBOT</b></font>',
|
|
||||||
ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)),
|
|
||||||
Spacer(1, 0.1 * cm),
|
|
||||||
Paragraph(f'<font color="#aaaaaa">{offer_number}</font>',
|
|
||||||
ps("anr", fontSize=8, textColor=C_GRAY, alignment=2, leading=12)),
|
|
||||||
],
|
|
||||||
]],
|
|
||||||
colWidths=[4.5 * cm, CONTENT_W - 4.5 * cm],
|
|
||||||
)
|
|
||||||
hdr.setStyle(TableStyle([
|
|
||||||
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
|
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("LEFTPADDING", (0, 0), (0, -1), 14),
|
|
||||||
("RIGHTPADDING", (-1, 0), (-1, -1), 14),
|
|
||||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
||||||
]))
|
|
||||||
story.append(hdr)
|
|
||||||
story.append(Spacer(1, 0.45 * cm))
|
|
||||||
|
|
||||||
# ── Meta block (recipient / date / validity) ──────────────────────────────
|
|
||||||
meta = Table(
|
|
||||||
[
|
|
||||||
[Paragraph("Empfänger", s_label), Paragraph(recipient_email, s_value),
|
|
||||||
Paragraph("Datum", s_label), Paragraph(today, s_body)],
|
|
||||||
[Paragraph("Plan", s_label), Paragraph(plan_name, s_body),
|
|
||||||
Paragraph("Gültig bis",s_label), Paragraph(validity, s_body)],
|
|
||||||
],
|
|
||||||
colWidths=[2.8 * cm, CONTENT_W / 2 - 2.8 * cm, 2.2 * cm, CONTENT_W / 2 - 2.2 * cm],
|
|
||||||
)
|
|
||||||
meta.setStyle(TableStyle([
|
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
|
||||||
]))
|
|
||||||
story.append(meta)
|
|
||||||
story.append(Spacer(1, 0.3 * cm))
|
|
||||||
story.append(HRFlowable(width="100%", thickness=1.5, color=C_HIGHLIGHT, spaceAfter=0.35 * cm))
|
|
||||||
|
|
||||||
# ── Intro (one compact line) ───────────────────────────────────────────────
|
|
||||||
story.append(Paragraph(
|
|
||||||
"Sehr geehrte Damen und Herren, nachfolgend erhalten Sie unser Angebot für die Nutzung der "
|
|
||||||
"<b>Onyva LinkedIn Content Automation</b> Plattform.",
|
|
||||||
s_body,
|
|
||||||
))
|
|
||||||
story.append(Spacer(1, 0.35 * cm))
|
|
||||||
|
|
||||||
# ── Section: Lizenzdetails ────────────────────────────────────────────────
|
|
||||||
story.append(Paragraph("LIZENZDETAILS", s_sec))
|
|
||||||
story.append(Spacer(1, 0.15 * cm))
|
|
||||||
|
|
||||||
# Compact limits bar (single row, two columns)
|
|
||||||
limits_bar = Table(
|
|
||||||
[[
|
|
||||||
Paragraph(f"<b>Mitarbeiter:</b> {max_employees} Nutzer", s_body),
|
|
||||||
Paragraph(f"<b>KI-Token-Limit:</b> {token_str}", s_body),
|
|
||||||
]],
|
|
||||||
colWidths=[CONTENT_W / 2, CONTENT_W / 2],
|
|
||||||
)
|
|
||||||
limits_bar.setStyle(TableStyle([
|
|
||||||
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
||||||
("BOTTOMPADDING",(0, 0), (-1, -1), 6),
|
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
|
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
||||||
]))
|
|
||||||
story.append(limits_bar)
|
|
||||||
story.append(Spacer(1, 0.25 * cm))
|
|
||||||
|
|
||||||
# Features: 2-column grid
|
|
||||||
FEATURES = [
|
|
||||||
("LinkedIn-Profil-Analyse", "KI-gestützt, einmalig pro Profil – Basis für individuelle Content-Strategie"),
|
|
||||||
("Post-Typen & Strategie", "Definierbare Typen mit Gewichtung, KI-Analyse & Strategieanpassung"),
|
|
||||||
("KI-Post-Erstellung", "Chat-basiert, iterativ, mit Qualitätsprüfung & Stilvalidierung"),
|
|
||||||
("Mitarbeiterverwaltung", "Posts verwalten, kommentieren, freigeben oder ablehnen per UI"),
|
|
||||||
("Research-Funktion", "Automatische Themenrecherche zu aktuellen Branchentrends"),
|
|
||||||
("Posting-Kalender", "Planung & Terminierung von Posts über alle Mitarbeiter hinweg"),
|
|
||||||
("E-Mail-Workflows", "Freigabe-Anfragen & Entscheidungsbenachrichtigungen per E-Mail"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def feat_cell(name, desc):
|
|
||||||
return Paragraph(
|
|
||||||
f'<font color="#2d3838"><b>✓ {name}</b></font><br/>'
|
|
||||||
f'<font size="7.5" color="#888888"> {desc}</font>',
|
|
||||||
ps(f"fc_{name[:4]}", fontSize=8.5, leading=13),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pair features into rows of 2
|
|
||||||
feat_rows = []
|
|
||||||
for i in range(0, len(FEATURES), 2):
|
|
||||||
left = feat_cell(*FEATURES[i])
|
|
||||||
right = feat_cell(*FEATURES[i + 1]) if i + 1 < len(FEATURES) else Paragraph("", s_small)
|
|
||||||
feat_rows.append([left, right])
|
|
||||||
|
|
||||||
feat_t = Table(feat_rows, colWidths=[HALF_W, HALF_W], hAlign="LEFT")
|
|
||||||
feat_t.setStyle(TableStyle([
|
|
||||||
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
|
||||||
("GRID", (0, 0), (-1, -1), 0.4, C_RULE),
|
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
||||||
]))
|
|
||||||
story.append(feat_t)
|
|
||||||
story.append(Spacer(1, 0.4 * cm))
|
|
||||||
|
|
||||||
# ── Section: Preisgestaltung ──────────────────────────────────────────────
|
|
||||||
story.append(Paragraph("PREISGESTALTUNG", s_sec))
|
|
||||||
story.append(Spacer(1, 0.15 * cm))
|
|
||||||
|
|
||||||
price_rows = [
|
|
||||||
[Paragraph("Preis", s_label), Paragraph(price_str, s_price)],
|
|
||||||
[Paragraph("Zahlungsweise", s_label), Paragraph(freq_cap, s_body)],
|
|
||||||
]
|
|
||||||
if payment_frequency == "jährlich":
|
|
||||||
price_rows.append([Paragraph("Jahresbetrag", s_label), Paragraph(_fmt_eur(price * 12), s_body)])
|
|
||||||
|
|
||||||
price_t = Table(price_rows, colWidths=[4 * cm, CONTENT_W - 4 * cm])
|
|
||||||
price_t.setStyle(TableStyle([
|
|
||||||
("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
|
|
||||||
("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
|
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
|
||||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
||||||
]))
|
|
||||||
story.append(price_t)
|
|
||||||
story.append(Spacer(1, 0.2 * cm))
|
|
||||||
story.append(Paragraph("Alle Preise zzgl. gesetzlicher Mehrwertsteuer.", s_small))
|
|
||||||
story.append(Spacer(1, 0.5 * cm))
|
|
||||||
|
|
||||||
# ── Footer ────────────────────────────────────────────────────────────────
|
|
||||||
story.append(HRFlowable(width="100%", thickness=0.5, color=C_RULE, spaceAfter=0.25 * cm))
|
|
||||||
story.append(Paragraph(
|
|
||||||
"Bei Fragen stehen wir Ihnen gerne zur Verfügung – Kontakt: <b>team@onyva.de</b>",
|
|
||||||
s_small,
|
|
||||||
))
|
|
||||||
story.append(Spacer(1, 0.35 * cm))
|
|
||||||
story.append(Paragraph("Mit freundlichen Grüßen,<br/><b>Onyva</b>", s_body))
|
|
||||||
|
|
||||||
doc.build(story)
|
|
||||||
return buffer.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/api/license-keys/{key_id}/send-offer")
|
|
||||||
async def send_license_offer(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
key_id: str,
|
key_id: str,
|
||||||
email: str = Form(...),
|
company_id: int = Form(...),
|
||||||
|
company_name: str = Form(...),
|
||||||
price: float = Form(...),
|
price: float = Form(...),
|
||||||
payment_frequency: str = Form(...),
|
payment_frequency: str = Form(...),
|
||||||
):
|
):
|
||||||
"""Generate a PDF offer and send it via email."""
|
"""Create an offer in MOCO for the given license key."""
|
||||||
if not verify_auth(request):
|
if not verify_auth(request):
|
||||||
raise HTTPException(status_code=401)
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
@@ -883,74 +665,66 @@ async def send_license_offer(
|
|||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
|
raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
|
||||||
|
|
||||||
pdf_bytes = _build_offer_pdf(
|
plan_name = key.description or "Standard Plan"
|
||||||
key_description=key.description or "",
|
offer = await moco_service.create_moco_offer(
|
||||||
max_employees=key.max_employees,
|
company_id=company_id,
|
||||||
daily_token_limit=key.daily_token_limit,
|
company_name=company_name,
|
||||||
price=price,
|
price=price,
|
||||||
payment_frequency=payment_frequency,
|
payment_frequency=payment_frequency,
|
||||||
recipient_email=email,
|
plan_name=plan_name,
|
||||||
|
max_employees=key.max_employees,
|
||||||
|
daily_token_limit=key.daily_token_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
posts_note = ""
|
offer_url = offer.get("web_url") or offer.get("url") or f"https://{settings.moco_domain}.mocoapp.com/offers/{offer.get('id', '')}"
|
||||||
if key.daily_token_limit:
|
|
||||||
posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST)
|
|
||||||
token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
|
|
||||||
else:
|
|
||||||
token_str = "Unbegrenzt"
|
|
||||||
|
|
||||||
price_str = f"{price:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
|
# Persist offer in DB
|
||||||
plan_name = key.description or "Standard Plan"
|
await db.create_license_key_offer(
|
||||||
|
license_key_id=UUID(key_id),
|
||||||
html_body = f"""
|
moco_offer_id=offer["id"],
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
|
moco_offer_identifier=offer.get("identifier"),
|
||||||
<div style="background: #2d3838; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
moco_offer_url=offer_url,
|
||||||
<h1 style="color: #ffc700; margin: 0; font-size: 22px;">LinkedIn Content Automation</h1>
|
offer_title=offer.get("title") or f"LinkedIn Content Automation – {plan_name}",
|
||||||
<p style="color: #cccccc; margin: 6px 0 0; font-size: 13px;">Ihr persönliches Angebot</p>
|
company_name=company_name,
|
||||||
</div>
|
price=price,
|
||||||
<div style="background: #f9f9f9; padding: 32px;">
|
payment_frequency=payment_frequency,
|
||||||
<p style="color: #333; font-size: 15px;">Sehr geehrte Damen und Herren,</p>
|
|
||||||
<p style="color: #333; font-size: 14px; line-height: 1.6;">
|
|
||||||
vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.<br>
|
|
||||||
Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument.
|
|
||||||
</p>
|
|
||||||
<div style="background: #2d3838; border-radius: 8px; padding: 20px 24px; margin: 24px 0;">
|
|
||||||
<h3 style="color: #ffc700; margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">Angebotsübersicht</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Plan</td><td style="color: #fff; font-size: 13px; font-weight: bold;">{plan_name}</td></tr>
|
|
||||||
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Mitarbeiter</td><td style="color: #fff; font-size: 13px;">{key.max_employees} Nutzer</td></tr>
|
|
||||||
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Token-Limit</td><td style="color: #fff; font-size: 13px;">{token_str}</td></tr>
|
|
||||||
<tr style="border-top: 1px solid #4a5858;"><td style="color: #aaa; font-size: 13px; padding: 8px 0 4px;">Preis</td><td style="color: #ffc700; font-size: 18px; font-weight: bold; padding-top: 6px;">{price_str}</td></tr>
|
|
||||||
<tr><td style="color: #aaa; font-size: 13px; padding: 4px 0;">Zahlungsweise</td><td style="color: #fff; font-size: 13px;">{payment_frequency.capitalize()}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p style="color: #666; font-size: 13px;">Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.</p>
|
|
||||||
<p style="color: #333; font-size: 14px;">Bei Fragen stehen wir Ihnen gerne zur Verfügung.</p>
|
|
||||||
<p style="color: #333; font-size: 14px;">Mit freundlichen Grüßen,<br><strong>Onyva</strong></p>
|
|
||||||
</div>
|
|
||||||
<div style="background: #2d3838; padding: 16px 32px; border-radius: 0 0 8px 8px; text-align: center;">
|
|
||||||
<p style="color: #888; font-size: 11px; margin: 0;">Onyva • team@onyva.de</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
|
|
||||||
offer_number = datetime.utcnow().strftime('%Y%m%d%H%M')
|
|
||||||
filename = f"Angebot_{offer_number}.pdf"
|
|
||||||
success = send_email_with_attachment(
|
|
||||||
to_email=email,
|
|
||||||
subject=f"Ihr Angebot – LinkedIn Content Automation ({plan_name})",
|
|
||||||
html_content=html_body,
|
|
||||||
attachment_bytes=pdf_bytes,
|
|
||||||
attachment_filename=filename,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
return JSONResponse({"success": True, "offer_url": offer_url})
|
||||||
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden. SMTP prüfen.")
|
|
||||||
|
|
||||||
return JSONResponse({"success": True, "message": f"Angebot erfolgreich an {email} gesendet."})
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending license offer: {e}")
|
logger.error(f"Error creating MOCO offer: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/api/license-key-offers/{offer_id}/send")
|
||||||
|
async def send_license_key_offer(request: Request, offer_id: str):
|
||||||
|
"""Send a stored MOCO offer via email and mark it as sent."""
|
||||||
|
if not verify_auth(request):
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.database.models import LicenseKeyOffer
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: db.client.table("license_key_offers")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", offer_id)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Angebot nicht gefunden")
|
||||||
|
|
||||||
|
stored = LicenseKeyOffer(**result.data[0])
|
||||||
|
await moco_service.send_moco_offer(stored.moco_offer_id)
|
||||||
|
await db.update_license_key_offer_status(UUID(offer_id), "sent")
|
||||||
|
|
||||||
|
return JSONResponse({"success": True})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending offer {offer_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-brand-bg-light">
|
<tbody class="divide-y divide-brand-bg-light">
|
||||||
{% for key in keys %}
|
{% for key in keys %}
|
||||||
|
{% set key_offers = offers_by_key.get(key.id | string, []) %}
|
||||||
<tr class="hover:bg-brand-bg/30">
|
<tr class="hover:bg-brand-bg/30">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="font-mono text-white font-medium">{{ key.key }}</div>
|
<div class="font-mono text-white font-medium">{{ key.key }}</div>
|
||||||
@@ -67,44 +68,42 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if key.used %}
|
{% if key.used %}
|
||||||
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
|
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">Verwendet</span>
|
||||||
Verwendet
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
|
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">Verfügbar</span>
|
||||||
Verfügbar
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-400">
|
<td class="px-6 py-4 text-sm text-gray-400">
|
||||||
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
|
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
|
||||||
|
{% if key_offers %}
|
||||||
|
<button onclick="toggleOffers('{{ key.id }}')"
|
||||||
|
class="ml-2 px-2 py-0.5 bg-brand-highlight/20 text-brand-highlight rounded text-xs font-medium hover:bg-brand-highlight/30 transition-colors">
|
||||||
|
📄 {{ key_offers | length }} Angebot{{ 'e' if key_offers | length != 1 else '' }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4 text-right">
|
||||||
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')"
|
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')"
|
||||||
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
|
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors" title="Bearbeiten">
|
||||||
title="Bearbeiten">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')"
|
<button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')"
|
||||||
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors"
|
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors" title="Angebot in MOCO erstellen">
|
||||||
title="Angebot senden">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{% if not key.used %}
|
{% if not key.used %}
|
||||||
<button onclick="copyKey('{{ key.key }}')"
|
<button onclick="copyKey('{{ key.key }}')"
|
||||||
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
|
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors" title="Kopieren">
|
||||||
title="Kopieren">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteKey('{{ key.id }}')"
|
<button onclick="deleteKey('{{ key.id }}')"
|
||||||
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
|
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors" title="Löschen">
|
||||||
title="Löschen">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -112,6 +111,41 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if key_offers %}
|
||||||
|
<tr id="offers-{{ key.id }}" class="hidden bg-brand-bg/20">
|
||||||
|
<td colspan="5" class="px-6 py-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for offer in key_offers %}
|
||||||
|
<div class="flex items-center justify-between bg-brand-bg rounded-lg px-4 py-2.5 border border-brand-bg-light">
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<a href="{{ offer.moco_offer_url }}" target="_blank"
|
||||||
|
class="font-medium text-white hover:text-brand-highlight transition-colors">
|
||||||
|
{{ offer.moco_offer_identifier or offer.offer_title or 'Angebot' }}
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">{{ offer.company_name }}</span>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
{% if offer.price %}{{ "%.2f"|format(offer.price) }} €{% endif %}
|
||||||
|
{% if offer.payment_frequency %} / {{ offer.payment_frequency }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 text-xs">{{ offer.created_at.strftime('%d.%m.%Y') if offer.created_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if offer.status == 'sent' %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-600/30 text-green-400 rounded text-xs">Versendet</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 rounded text-xs">Entwurf</span>
|
||||||
|
<button onclick="openSendModal('{{ offer.id }}', '{{ offer.moco_offer_identifier or offer.offer_title }}', '{{ offer.company_name }}')"
|
||||||
|
class="px-3 py-1 bg-brand-highlight text-brand-bg rounded text-xs font-semibold hover:bg-brand-highlight-dark transition-colors">
|
||||||
|
Versenden
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -166,13 +200,14 @@
|
|||||||
<!-- Offer Modal -->
|
<!-- Offer Modal -->
|
||||||
<div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4">
|
<div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4">
|
||||||
<h2 class="text-2xl font-bold text-white mb-1">Angebot senden</h2>
|
<h2 class="text-2xl font-bold text-white mb-1">Angebot in MOCO erstellen</h2>
|
||||||
<p class="text-gray-400 text-sm mb-6">Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.</p>
|
<p class="text-gray-400 text-sm mb-6">Erstellt ein Angebot direkt in MOCO. Versand erfolgt manuell in MOCO.</p>
|
||||||
|
|
||||||
<form id="offerForm" class="space-y-4">
|
<form id="offerForm" class="space-y-4">
|
||||||
<input type="hidden" id="offer_key_id" name="key_id">
|
<input type="hidden" id="offer_key_id" name="key_id">
|
||||||
|
<input type="hidden" id="offer_company_name_hidden" name="company_name">
|
||||||
|
|
||||||
<!-- Calculated Info -->
|
<!-- Plan Info -->
|
||||||
<div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600">
|
<div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600">
|
||||||
<div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div>
|
<div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div>
|
||||||
<div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div>
|
<div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div>
|
||||||
@@ -181,6 +216,16 @@
|
|||||||
<div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div>
|
<div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Dropdown -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Firma (MOCO)</label>
|
||||||
|
<select id="offer_company_select" name="company_id" required
|
||||||
|
onchange="onCompanySelect(this)"
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
|
||||||
|
<option value="">Wird geladen…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Price -->
|
<!-- Price -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
|
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
|
||||||
@@ -213,20 +258,13 @@
|
|||||||
Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span>
|
Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail-Adresse</label>
|
|
||||||
<input type="email" id="offer_email" name="email" placeholder="kunde@beispiel.de" required
|
|
||||||
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div>
|
<div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<button type="submit" id="offerSubmitBtn"
|
<button type="submit" id="offerSubmitBtn"
|
||||||
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
|
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
Angebot senden
|
Angebot in MOCO erstellen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="closeOfferModal()"
|
<button type="button" onclick="closeOfferModal()"
|
||||||
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
||||||
@@ -237,6 +275,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Offer Confirmation Modal -->
|
||||||
|
<div id="sendModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-1">Angebot versenden</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-6">Das Angebot wird per E-Mail an die hinterlegten Empfänger in MOCO gesendet.</p>
|
||||||
|
|
||||||
|
<div id="sendOfferInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600 mb-6">
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Angebot:</span><span id="send_offer_title" class="text-white font-medium"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-400">Firma:</span><span id="send_offer_company" class="text-white"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="send-status" class="hidden text-sm rounded-lg px-4 py-3 mb-4"></div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button id="sendConfirmBtn" onclick="confirmSendOffer()"
|
||||||
|
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
|
Jetzt versenden
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeSendModal()"
|
||||||
|
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Generate Modal -->
|
<!-- Generate Modal -->
|
||||||
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
||||||
@@ -374,7 +439,7 @@ async function deleteKey(keyId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Offer Modal ────────────────────────────────────────────────────────────
|
// ── Offer Modal (MOCO) ─────────────────────────────────────────────────────
|
||||||
const AVG_TOKENS_PER_POST = 50000;
|
const AVG_TOKENS_PER_POST = 50000;
|
||||||
const API_COST_PER_1K_EUR = 0.003;
|
const API_COST_PER_1K_EUR = 0.003;
|
||||||
const SERVER_SHARE_EUR = 1.60;
|
const SERVER_SHARE_EUR = 1.60;
|
||||||
@@ -384,12 +449,10 @@ function calcSuggestedPrice(dailyTokenLimit) {
|
|||||||
const monthlyTokens = dailyTokenLimit * 30;
|
const monthlyTokens = dailyTokenLimit * 30;
|
||||||
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
|
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
|
||||||
const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5;
|
const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5;
|
||||||
return Math.ceil(raw / 5) * 5; // round up to nearest 5€
|
return Math.ceil(raw / 5) * 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtNum(n) {
|
function fmtNum(n) { return n.toLocaleString('de-DE'); }
|
||||||
return n.toLocaleString('de-DE');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
|
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
|
||||||
document.getElementById('offer_key_id').value = keyId;
|
document.getElementById('offer_key_id').value = keyId;
|
||||||
@@ -409,8 +472,13 @@ function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
|
|||||||
document.getElementById('offer_suggested').textContent = '–';
|
document.getElementById('offer_suggested').textContent = '–';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset company select
|
||||||
|
const sel = document.getElementById('offer_company_select');
|
||||||
|
sel.innerHTML = '<option value="">Wird geladen…</option>';
|
||||||
|
document.getElementById('offer_company_name_hidden').value = '';
|
||||||
|
loadCompanies();
|
||||||
|
|
||||||
selectFreq('monatlich');
|
selectFreq('monatlich');
|
||||||
document.getElementById('offer_email').value = '';
|
|
||||||
document.getElementById('offer-status').classList.add('hidden');
|
document.getElementById('offer-status').classList.add('hidden');
|
||||||
document.getElementById('offerModal').classList.remove('hidden');
|
document.getElementById('offerModal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -423,10 +491,8 @@ function closeOfferModal() {
|
|||||||
function selectFreq(freq) {
|
function selectFreq(freq) {
|
||||||
document.getElementById('offer_payment_frequency').value = freq;
|
document.getElementById('offer_payment_frequency').value = freq;
|
||||||
['monatlich', 'jährlich', 'einmalig'].forEach(f => {
|
['monatlich', 'jährlich', 'einmalig'].forEach(f => {
|
||||||
const btn = document.getElementById('freq-' + f);
|
document.getElementById('freq-' + f).classList.toggle('active-freq', f === freq);
|
||||||
btn.classList.toggle('active-freq', f === freq);
|
|
||||||
});
|
});
|
||||||
// Show yearly total hint
|
|
||||||
const price = parseFloat(document.getElementById('offer_price').value) || 0;
|
const price = parseFloat(document.getElementById('offer_price').value) || 0;
|
||||||
const hint = document.getElementById('yearly-hint');
|
const hint = document.getElementById('yearly-hint');
|
||||||
if (freq === 'jährlich' && price > 0) {
|
if (freq === 'jährlich' && price > 0) {
|
||||||
@@ -438,43 +504,141 @@ function selectFreq(freq) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('offer_price').addEventListener('input', () => {
|
document.getElementById('offer_price').addEventListener('input', () => {
|
||||||
if (document.getElementById('offer_payment_frequency').value === 'jährlich') {
|
if (document.getElementById('offer_payment_frequency').value === 'jährlich') selectFreq('jährlich');
|
||||||
selectFreq('jährlich');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Company select ─────────────────────────────────────────────────────────
|
||||||
|
async function loadCompanies() {
|
||||||
|
const sel = document.getElementById('offer_company_select');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/moco/companies');
|
||||||
|
if (!resp.ok) throw new Error('Fehler beim Laden');
|
||||||
|
const companies = await resp.json();
|
||||||
|
sel.innerHTML = '<option value="">Firma auswählen…</option>';
|
||||||
|
companies.forEach(c => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = c.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sel.innerHTML = '<option value="">Fehler beim Laden der Firmen</option>';
|
||||||
|
console.error('Company load error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCompanySelect(sel) {
|
||||||
|
const name = sel.options[sel.selectedIndex]?.text || '';
|
||||||
|
document.getElementById('offer_company_name_hidden').value = sel.value ? name : '';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('offerForm').addEventListener('submit', async (e) => {
|
document.getElementById('offerForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
const keyId = formData.get('key_id');
|
const keyId = formData.get('key_id');
|
||||||
|
const companyId = formData.get('company_id');
|
||||||
const statusEl = document.getElementById('offer-status');
|
const statusEl = document.getElementById('offer-status');
|
||||||
const submitBtn = document.getElementById('offerSubmitBtn');
|
const submitBtn = document.getElementById('offerSubmitBtn');
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
|
||||||
|
statusEl.textContent = 'Bitte eine Firma auswählen.';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Wird gesendet…';
|
submitBtn.textContent = 'Wird erstellt…';
|
||||||
statusEl.classList.add('hidden');
|
statusEl.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/admin/api/license-keys/${keyId}/send-offer`, {
|
const response = await fetch(`/admin/api/license-keys/${keyId}/create-offer`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.detail || 'Fehler beim Erstellen');
|
||||||
if (!response.ok) throw new Error(data.detail || 'Fehler beim Senden');
|
|
||||||
|
|
||||||
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
|
||||||
statusEl.textContent = data.message || 'Angebot erfolgreich gesendet!';
|
statusEl.innerHTML = 'Angebot erfolgreich erstellt! '
|
||||||
|
+ (data.offer_url ? `<a href="${data.offer_url}" target="_blank" class="underline font-semibold text-green-300 hover:text-white">In MOCO öffnen →</a>` : '');
|
||||||
statusEl.classList.remove('hidden');
|
statusEl.classList.remove('hidden');
|
||||||
submitBtn.textContent = '✓ Gesendet';
|
submitBtn.textContent = '✓ Erstellt';
|
||||||
|
// Reset button after 4 seconds so another offer can be created
|
||||||
|
setTimeout(() => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
|
||||||
|
location.reload();
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
|
||||||
statusEl.textContent = 'Fehler: ' + error.message;
|
statusEl.textContent = 'Fehler: ' + error.message;
|
||||||
statusEl.classList.remove('hidden');
|
statusEl.classList.remove('hidden');
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Angebot senden';
|
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Offer sub-rows toggle ───────────────────────────────────────────────────
|
||||||
|
function toggleOffers(keyId) {
|
||||||
|
const row = document.getElementById('offers-' + keyId);
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send Offer Modal ────────────────────────────────────────────────────────
|
||||||
|
let currentSendOfferId = null;
|
||||||
|
|
||||||
|
function openSendModal(offerId, title, company) {
|
||||||
|
currentSendOfferId = offerId;
|
||||||
|
document.getElementById('send_offer_title').textContent = title || 'Angebot';
|
||||||
|
document.getElementById('send_offer_company').textContent = company || '–';
|
||||||
|
const statusEl = document.getElementById('send-status');
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
const btn = document.getElementById('sendConfirmBtn');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Jetzt versenden';
|
||||||
|
document.getElementById('sendModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSendModal() {
|
||||||
|
document.getElementById('sendModal').classList.add('hidden');
|
||||||
|
currentSendOfferId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSendOffer() {
|
||||||
|
if (!currentSendOfferId) return;
|
||||||
|
const statusEl = document.getElementById('send-status');
|
||||||
|
const btn = document.getElementById('sendConfirmBtn');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird versendet…';
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/api/license-key-offers/${currentSendOfferId}/send`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.detail || 'Fehler beim Versenden');
|
||||||
|
|
||||||
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
|
||||||
|
statusEl.textContent = 'Angebot erfolgreich versendet!';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '✓ Versendet';
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSendModal();
|
||||||
|
location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
|
||||||
|
statusEl.textContent = 'Fehler: ' + error.message;
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Erneut versuchen';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -705,8 +705,8 @@
|
|||||||
<div id="editView" class="hidden">
|
<div id="editView" class="hidden">
|
||||||
<textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea>
|
<textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea>
|
||||||
<div class="flex items-center justify-between mt-4">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-gray-400">
|
<span class="text-sm" id="charCountLabel">
|
||||||
<span id="charCount">{{ post.post_content | length }}</span> Zeichen
|
<span id="charCount">{{ post.post_content | length }}</span> / 3000 Zeichen
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button onclick="cancelEdit()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors">
|
<button onclick="cancelEdit()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors">
|
||||||
@@ -1137,7 +1137,16 @@ function setPreviewMode(mode) {
|
|||||||
function updateCharCount() {
|
function updateCharCount() {
|
||||||
const textarea = document.getElementById('editTextarea');
|
const textarea = document.getElementById('editTextarea');
|
||||||
const charCount = document.getElementById('charCount');
|
const charCount = document.getElementById('charCount');
|
||||||
charCount.textContent = textarea.value.length;
|
const label = document.getElementById('charCountLabel');
|
||||||
|
const len = textarea.value.length;
|
||||||
|
charCount.textContent = len;
|
||||||
|
if (len > 3000) {
|
||||||
|
label.style.color = '#ef4444';
|
||||||
|
} else if (len > 2700) {
|
||||||
|
label.style.color = '#f59e0b';
|
||||||
|
} else {
|
||||||
|
label.style.color = '#9ca3af';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -1154,6 +1163,11 @@ async function saveEdit() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newContent.length > 3000) {
|
||||||
|
showToast('Post überschreitet das LinkedIn-Limit von 3000 Zeichen.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const originalBtnHTML = saveBtn.innerHTML;
|
const originalBtnHTML = saveBtn.innerHTML;
|
||||||
saveBtn.innerHTML = '<div class="loading-spinner"></div>';
|
saveBtn.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
@@ -1979,10 +1993,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Initialize media upload (multi-media support)
|
// Initialize media upload (multi-media support)
|
||||||
initMediaUpload();
|
initMediaUpload();
|
||||||
|
|
||||||
// Add event listener for textarea character count
|
// Add event listener for textarea character count + initialize color
|
||||||
const textarea = document.getElementById('editTextarea');
|
const textarea = document.getElementById('editTextarea');
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.addEventListener('input', updateCharCount);
|
textarea.addEventListener('input', updateCharCount);
|
||||||
|
updateCharCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow Enter key to apply custom suggestion
|
// Allow Enter key to apply custom suggestion
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from src.services.email_service import (
|
|||||||
send_approval_request_email,
|
send_approval_request_email,
|
||||||
send_decision_notification_email,
|
send_decision_notification_email,
|
||||||
validate_token,
|
validate_token,
|
||||||
mark_token_used
|
mark_token_used,
|
||||||
)
|
)
|
||||||
from src.services.background_jobs import (
|
from src.services.background_jobs import (
|
||||||
job_manager, JobType, JobStatus,
|
job_manager, JobType, JobStatus,
|
||||||
@@ -1241,36 +1241,41 @@ async def dashboard(request: Request):
|
|||||||
|
|
||||||
if session.company_id:
|
if session.company_id:
|
||||||
try:
|
try:
|
||||||
company = await db.get_company(UUID(session.company_id))
|
company_id_uuid = UUID(session.company_id)
|
||||||
employees_raw = await db.get_company_employees(UUID(session.company_id))
|
company, employees_raw, pending_invitations, quota, license_key = await asyncio.gather(
|
||||||
pending_invitations = await db.get_pending_invitations(UUID(session.company_id))
|
db.get_company(company_id_uuid),
|
||||||
quota = await db.get_company_daily_quota(UUID(session.company_id))
|
db.get_company_employees(company_id_uuid),
|
||||||
license_key = await db.get_company_limits(UUID(session.company_id))
|
db.get_pending_invitations(company_id_uuid),
|
||||||
|
db.get_company_daily_quota(company_id_uuid),
|
||||||
|
db.get_company_limits(company_id_uuid),
|
||||||
|
)
|
||||||
except Exception as company_error:
|
except Exception as company_error:
|
||||||
logger.warning(f"Could not load company data for {session.company_id}: {company_error}")
|
logger.warning(f"Could not load company data for {session.company_id}: {company_error}")
|
||||||
# Continue without company data - better than crashing
|
# Continue without company data - better than crashing
|
||||||
|
|
||||||
# Add avatar URLs to employees
|
# Add avatar URLs to employees (parallel)
|
||||||
employees = []
|
def _make_emp_session(emp):
|
||||||
for emp in employees_raw:
|
return UserSession(
|
||||||
emp_session = UserSession(
|
|
||||||
user_id=str(emp.id),
|
user_id=str(emp.id),
|
||||||
linkedin_picture=emp.linkedin_picture,
|
linkedin_picture=emp.linkedin_picture,
|
||||||
email=emp.email,
|
email=emp.email,
|
||||||
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
|
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
|
||||||
display_name=emp.display_name
|
display_name=emp.display_name
|
||||||
)
|
)
|
||||||
avatar_url = await get_user_avatar(emp_session, emp.id)
|
|
||||||
|
|
||||||
# Create employee dict with avatar
|
emp_sessions = [_make_emp_session(emp) for emp in employees_raw]
|
||||||
emp_dict = {
|
avatar_urls = await asyncio.gather(*[get_user_avatar(s, emp.id) for s, emp in zip(emp_sessions, employees_raw)])
|
||||||
|
|
||||||
|
employees = [
|
||||||
|
{
|
||||||
"id": emp.id,
|
"id": emp.id,
|
||||||
"email": emp.email,
|
"email": emp.email,
|
||||||
"display_name": emp.display_name or emp.linkedin_name or emp.email,
|
"display_name": emp.display_name or emp.linkedin_name or emp.email,
|
||||||
"onboarding_status": emp.onboarding_status,
|
"onboarding_status": emp.onboarding_status,
|
||||||
"avatar_url": avatar_url
|
"avatar_url": avatar_url
|
||||||
}
|
}
|
||||||
employees.append(emp_dict)
|
for emp, avatar_url in zip(employees_raw, avatar_urls)
|
||||||
|
]
|
||||||
|
|
||||||
user_id = UUID(session.user_id)
|
user_id = UUID(session.user_id)
|
||||||
profile_picture = await get_user_avatar(session, user_id)
|
profile_picture = await get_user_avatar(session, user_id)
|
||||||
@@ -1586,14 +1591,13 @@ async def post_detail_page(request: Request, post_id: str):
|
|||||||
if str(post.user_id) != session.user_id:
|
if str(post.user_id) != session.user_id:
|
||||||
return RedirectResponse(url="/posts", status_code=302)
|
return RedirectResponse(url="/posts", status_code=302)
|
||||||
|
|
||||||
profile = await db.get_profile(post.user_id)
|
profile, linkedin_posts, profile_picture_url, profile_analysis_record = await asyncio.gather(
|
||||||
linkedin_posts = await db.get_linkedin_posts(post.user_id)
|
db.get_profile(post.user_id),
|
||||||
|
db.get_linkedin_posts(post.user_id),
|
||||||
|
get_user_avatar(session, post.user_id),
|
||||||
|
db.get_profile_analysis(post.user_id),
|
||||||
|
)
|
||||||
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
||||||
|
|
||||||
# Get avatar with priority: LinkedInAccount > profile_picture > session.linkedin_picture
|
|
||||||
profile_picture_url = await get_user_avatar(session, post.user_id)
|
|
||||||
|
|
||||||
profile_analysis_record = await db.get_profile_analysis(post.user_id)
|
|
||||||
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
||||||
|
|
||||||
post_type = None
|
post_type = None
|
||||||
@@ -2198,7 +2202,7 @@ async def update_post_status(
|
|||||||
if status == "approved" and profile and profile.customer_email:
|
if status == "approved" and profile and profile.customer_email:
|
||||||
# Build base URL from request
|
# Build base URL from request
|
||||||
base_url = str(request.base_url).rstrip('/')
|
base_url = str(request.base_url).rstrip('/')
|
||||||
email_sent = send_approval_request_email(
|
email_sent = await send_approval_request_email(
|
||||||
to_email=profile.customer_email,
|
to_email=profile.customer_email,
|
||||||
post_id=UUID(post_id),
|
post_id=UUID(post_id),
|
||||||
post_title=post.topic_title or "Untitled Post",
|
post_title=post.topic_title or "Untitled Post",
|
||||||
@@ -2235,6 +2239,9 @@ async def update_post(
|
|||||||
if str(post.user_id) != session.user_id:
|
if str(post.user_id) != session.user_id:
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
if len(content) > 3000:
|
||||||
|
raise HTTPException(status_code=422, detail="Post überschreitet 3000-Zeichen-Limit")
|
||||||
|
|
||||||
# Save as new version
|
# Save as new version
|
||||||
writer_versions = post.writer_versions or []
|
writer_versions = post.writer_versions or []
|
||||||
writer_versions.append(content)
|
writer_versions.append(content)
|
||||||
@@ -2265,7 +2272,7 @@ async def update_post(
|
|||||||
@user_router.get("/api/email-action/{token}", response_class=HTMLResponse)
|
@user_router.get("/api/email-action/{token}", response_class=HTMLResponse)
|
||||||
async def handle_email_action(request: Request, token: str):
|
async def handle_email_action(request: Request, token: str):
|
||||||
"""Handle email action (approve/reject) from email link."""
|
"""Handle email action (approve/reject) from email link."""
|
||||||
token_data = validate_token(token)
|
token_data = await validate_token(token)
|
||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
return templates.TemplateResponse("email_action_result.html", {
|
return templates.TemplateResponse("email_action_result.html", {
|
||||||
@@ -2306,7 +2313,7 @@ async def handle_email_action(request: Request, token: str):
|
|||||||
await db.update_generated_post(post_id, {"status": new_status})
|
await db.update_generated_post(post_id, {"status": new_status})
|
||||||
|
|
||||||
# Mark token as used
|
# Mark token as used
|
||||||
mark_token_used(token)
|
await mark_token_used(token)
|
||||||
|
|
||||||
# Send notification to creator
|
# Send notification to creator
|
||||||
if profile and profile.creator_email:
|
if profile and profile.creator_email:
|
||||||
@@ -2713,27 +2720,30 @@ async def company_manage_page(request: Request, employee_id: str = None):
|
|||||||
all_employees = await db.get_company_employees(company_id)
|
all_employees = await db.get_company_employees(company_id)
|
||||||
active_employees = [emp for emp in all_employees if emp.onboarding_status == "completed"]
|
active_employees = [emp for emp in all_employees if emp.onboarding_status == "completed"]
|
||||||
|
|
||||||
# Build display info for employees with correct avatar URLs
|
# Build display info for employees with correct avatar URLs (parallel)
|
||||||
# Note: emp is a User object from get_company_employees which has linkedin_name and linkedin_picture
|
# Note: emp is a User object from get_company_employees which has linkedin_name and linkedin_picture
|
||||||
active_employees_info = []
|
def _make_emp_session_manage(emp):
|
||||||
for emp in active_employees:
|
return UserSession(
|
||||||
# Create minimal session for employee to get avatar
|
|
||||||
emp_session = UserSession(
|
|
||||||
user_id=str(emp.id),
|
user_id=str(emp.id),
|
||||||
linkedin_picture=emp.linkedin_picture,
|
linkedin_picture=emp.linkedin_picture,
|
||||||
email=emp.email,
|
email=emp.email,
|
||||||
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
|
account_type=emp.account_type.value if hasattr(emp.account_type, 'value') else emp.account_type,
|
||||||
display_name=emp.display_name
|
display_name=emp.display_name
|
||||||
)
|
)
|
||||||
avatar_url = await get_user_avatar(emp_session, emp.id)
|
|
||||||
|
|
||||||
active_employees_info.append({
|
emp_sessions_manage = [_make_emp_session_manage(emp) for emp in active_employees]
|
||||||
|
emp_avatars = await asyncio.gather(*[get_user_avatar(s, emp.id) for s, emp in zip(emp_sessions_manage, active_employees)])
|
||||||
|
|
||||||
|
active_employees_info = [
|
||||||
|
{
|
||||||
"id": str(emp.id),
|
"id": str(emp.id),
|
||||||
"email": emp.email,
|
"email": emp.email,
|
||||||
"display_name": emp.linkedin_name or emp.display_name or emp.email,
|
"display_name": emp.linkedin_name or emp.display_name or emp.email,
|
||||||
"linkedin_picture": avatar_url, # Now contains the correct avatar with priority
|
"linkedin_picture": avatar_url,
|
||||||
"onboarding_status": emp.onboarding_status
|
"onboarding_status": emp.onboarding_status
|
||||||
})
|
}
|
||||||
|
for emp, avatar_url in zip(active_employees, emp_avatars)
|
||||||
|
]
|
||||||
|
|
||||||
# Selected employee data
|
# Selected employee data
|
||||||
selected_employee = None
|
selected_employee = None
|
||||||
@@ -2749,10 +2759,11 @@ async def company_manage_page(request: Request, employee_id: str = None):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if selected_employee:
|
if selected_employee:
|
||||||
# Get employee's user_id
|
emp_profile, employee_posts = await asyncio.gather(
|
||||||
emp_profile = await db.get_profile(UUID(employee_id))
|
db.get_profile(UUID(employee_id)),
|
||||||
|
db.get_generated_posts(UUID(employee_id)),
|
||||||
|
)
|
||||||
if emp_profile:
|
if emp_profile:
|
||||||
employee_posts = await db.get_generated_posts(emp_profile.id)
|
|
||||||
pending_posts = len([p for p in employee_posts if p.status in ['draft', 'pending']])
|
pending_posts = len([p for p in employee_posts if p.status in ['draft', 'pending']])
|
||||||
approved_posts = len([p for p in employee_posts if p.status in ['approved', 'published']])
|
approved_posts = len([p for p in employee_posts if p.status in ['approved', 'published']])
|
||||||
|
|
||||||
@@ -2827,24 +2838,25 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
|
|||||||
if not employee_id:
|
if not employee_id:
|
||||||
return RedirectResponse(url="/company/manage", status_code=302)
|
return RedirectResponse(url="/company/manage", status_code=302)
|
||||||
|
|
||||||
# Get employee info
|
# Get employee info + post in parallel
|
||||||
emp_profile = await db.get_profile(UUID(employee_id))
|
emp_profile, emp_user, post = await asyncio.gather(
|
||||||
|
db.get_profile(UUID(employee_id)),
|
||||||
|
db.get_user(UUID(employee_id)),
|
||||||
|
db.get_generated_post(UUID(post_id)),
|
||||||
|
)
|
||||||
if not emp_profile:
|
if not emp_profile:
|
||||||
return RedirectResponse(url="/company/manage", status_code=302)
|
return RedirectResponse(url="/company/manage", status_code=302)
|
||||||
|
|
||||||
# Verify employee belongs to this company
|
# Verify employee belongs to this company
|
||||||
emp_user = await db.get_user(UUID(employee_id))
|
|
||||||
if not emp_user or str(emp_user.company_id) != session.company_id:
|
if not emp_user or str(emp_user.company_id) != session.company_id:
|
||||||
return RedirectResponse(url="/company/manage", status_code=302)
|
return RedirectResponse(url="/company/manage", status_code=302)
|
||||||
|
|
||||||
post = await db.get_generated_post(UUID(post_id))
|
|
||||||
if not post or str(post.user_id) != str(emp_profile.id):
|
if not post or str(post.user_id) != str(emp_profile.id):
|
||||||
return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302)
|
return RedirectResponse(url=f"/company/manage/posts?employee_id={employee_id}", status_code=302)
|
||||||
|
|
||||||
profile = await db.get_profile(emp_profile.id)
|
profile = emp_profile # same object - no second DB call needed
|
||||||
|
|
||||||
# Get employee's avatar
|
# Get employee's avatar
|
||||||
# Note: Create minimal session for the employee to use get_user_avatar
|
|
||||||
emp_session = UserSession(
|
emp_session = UserSession(
|
||||||
user_id=str(emp_profile.id),
|
user_id=str(emp_profile.id),
|
||||||
linkedin_picture=emp_user.linkedin_picture,
|
linkedin_picture=emp_user.linkedin_picture,
|
||||||
|
|||||||
Reference in New Issue
Block a user