diff --git a/Angebot.pdf b/Angebot.pdf new file mode 100644 index 0000000..adeb329 Binary files /dev/null and b/Angebot.pdf differ diff --git a/src/config.py b/src/config.py index 4082f49..8bfb946 100644 --- a/src/config.py +++ b/src/config.py @@ -61,6 +61,10 @@ class Settings(BaseSettings): # Token Encryption encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + # MOCO Integration + moco_api_key: str = "" # Token für Authorization-Header + moco_domain: str = "" # Subdomain: {domain}.mocoapp.com + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/src/database/client.py b/src/database/client.py index 15d0a84..0d438fc 100644 --- a/src/database/client.py +++ b/src/database/client.py @@ -11,7 +11,7 @@ from src.database.models import ( LinkedInProfile, LinkedInPost, Topic, ProfileAnalysis, ResearchResult, GeneratedPost, PostType, User, Profile, Company, Invitation, ExamplePost, ReferenceProfile, - ApiUsageLog, LicenseKey, CompanyDailyQuota + ApiUsageLog, LicenseKey, CompanyDailyQuota, LicenseKeyOffer ) @@ -1252,6 +1252,59 @@ class DatabaseClient: ) logger.info(f"Deleted license key: {key_id}") + # ==================== LICENSE KEY OFFERS ==================== + + async def create_license_key_offer( + self, + license_key_id: UUID, + moco_offer_id: int, + moco_offer_identifier: Optional[str], + moco_offer_url: Optional[str], + offer_title: Optional[str], + company_name: Optional[str], + price: Optional[float], + payment_frequency: Optional[str], + ) -> "LicenseKeyOffer": + """Save a MOCO offer linked to a license key.""" + data = { + "license_key_id": str(license_key_id), + "moco_offer_id": moco_offer_id, + "moco_offer_identifier": moco_offer_identifier, + "moco_offer_url": moco_offer_url, + "offer_title": offer_title, + "company_name": company_name, + "price": price, + "payment_frequency": payment_frequency, + "status": "draft", + } + result = await asyncio.to_thread( + lambda: self.client.table("license_key_offers").insert(data).execute() + ) + return LicenseKeyOffer(**result.data[0]) + + async def list_license_key_offers(self, license_key_id: UUID) -> list["LicenseKeyOffer"]: + """List all MOCO offers for a license key.""" + result = await asyncio.to_thread( + lambda: self.client.table("license_key_offers") + .select("*") + .eq("license_key_id", str(license_key_id)) + .order("created_at", desc=True) + .execute() + ) + return [LicenseKeyOffer(**row) for row in result.data] + + async def update_license_key_offer_status( + self, offer_id: UUID, status: str + ) -> "LicenseKeyOffer": + """Update the status of a stored offer.""" + result = await asyncio.to_thread( + lambda: self.client.table("license_key_offers") + .update({"status": status}) + .eq("id", str(offer_id)) + .execute() + ) + return LicenseKeyOffer(**result.data[0]) + # ==================== COMPANY QUOTAS ==================== async def get_company_daily_quota( @@ -1356,6 +1409,66 @@ class DatabaseClient: return True, "" + # ==================== EMAIL ACTION TOKENS ==================== + + async def create_email_token(self, token: str, post_id: UUID, action: str, expires_hours: int = 72) -> None: + """Store an email action token in the database.""" + from datetime import timedelta + expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours) + data = { + "token": token, + "post_id": str(post_id), + "action": action, + "expires_at": expires_at.isoformat(), + "used": False, + } + await asyncio.to_thread( + lambda: self.client.table("email_action_tokens").insert(data).execute() + ) + logger.debug(f"Created email token for post {post_id} action={action}") + + async def get_email_token(self, token: str) -> Optional[Dict[str, Any]]: + """Retrieve email token data; returns None if not found.""" + result = await asyncio.to_thread( + lambda: self.client.table("email_action_tokens").select("*").eq("token", token).execute() + ) + if not result.data: + return None + return result.data[0] + + async def mark_email_token_used(self, token: str) -> None: + """Mark an email token as used.""" + await asyncio.to_thread( + lambda: self.client.table("email_action_tokens").update({"used": True}).eq("token", token).execute() + ) + + async def cleanup_expired_email_tokens(self) -> None: + """Delete expired email tokens from the database.""" + now = datetime.now(timezone.utc).isoformat() + result = await asyncio.to_thread( + lambda: self.client.table("email_action_tokens").delete().lt("expires_at", now).execute() + ) + count = len(result.data) if result.data else 0 + if count: + logger.info(f"Cleaned up {count} expired email tokens") + + # ==================== LINKEDIN TOKEN REFRESH ==================== + + async def get_expiring_linkedin_accounts(self, within_days: int = 7) -> list: + """Return active LinkedIn accounts whose tokens expire within within_days and have a refresh_token.""" + from src.database.models import LinkedInAccount + from datetime import timedelta + cutoff = (datetime.now(timezone.utc) + timedelta(days=within_days)).isoformat() + result = await asyncio.to_thread( + lambda: self.client.table("linkedin_accounts") + .select("*") + .eq("is_active", True) + .lt("token_expires_at", cutoff) + .not_.is_("refresh_token", "null") + .execute() + ) + return [LinkedInAccount(**row) for row in result.data] + # Global database client instance db = DatabaseClient() diff --git a/src/database/models.py b/src/database/models.py index 98e2fd1..dbfb755 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -376,6 +376,21 @@ class LicenseKey(DBModel): updated_at: Optional[datetime] = None +class LicenseKeyOffer(DBModel): + """MOCO offer created for a license key.""" + id: Optional[UUID] = None + license_key_id: UUID + moco_offer_id: int + moco_offer_identifier: Optional[str] = None + moco_offer_url: Optional[str] = None + offer_title: Optional[str] = None + company_name: Optional[str] = None + price: Optional[float] = None + payment_frequency: Optional[str] = None + status: str = "draft" + created_at: Optional[datetime] = None + + class CompanyDailyQuota(DBModel): """Daily usage quota for a company.""" id: Optional[UUID] = None diff --git a/src/services/email_service.py b/src/services/email_service.py index 3f9d505..72da2ec 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -6,48 +6,49 @@ from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders from typing import Optional, Dict, Any -from datetime import datetime, timedelta +from datetime import datetime, timezone from uuid import UUID from loguru import logger from src.config import settings -# In-memory token store (in production, use Redis or database) -_email_tokens: Dict[str, Dict[str, Any]] = {} - -def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str: - """Generate a unique token for email action.""" +async def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str: + """Generate a unique token for email action and persist it to the database.""" + from src.database.client import db token = secrets.token_urlsafe(32) - _email_tokens[token] = { - "post_id": str(post_id), - "action": action, - "expires_at": datetime.utcnow() + timedelta(hours=expires_hours), - "used": False - } + await db.create_email_token(token, post_id, action, expires_hours) return token -def validate_token(token: str) -> Optional[Dict[str, Any]]: - """Validate and return token data if valid.""" - if token not in _email_tokens: +async def validate_token(token: str) -> Optional[Dict[str, Any]]: + """Validate and return token data if valid (checks DB).""" + from src.database.client import db + token_data = await db.get_email_token(token) + if not token_data: return None - token_data = _email_tokens[token] - - if token_data["used"]: + if token_data.get("used"): return None - if datetime.utcnow() > token_data["expires_at"]: - return None + expires_at_raw = token_data.get("expires_at") + if expires_at_raw: + if isinstance(expires_at_raw, str): + expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00")) + else: + expires_at = expires_at_raw + if not expires_at.tzinfo: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) > expires_at: + return None return token_data -def mark_token_used(token: str): - """Mark a token as used.""" - if token in _email_tokens: - _email_tokens[token]["used"] = True +async def mark_token_used(token: str) -> None: + """Mark a token as used in the database.""" + from src.database.client import db + await db.mark_email_token_used(token) def send_email(to_email: str, subject: str, html_content: str) -> bool: @@ -119,7 +120,7 @@ def send_email_with_attachment( return False -def send_approval_request_email( +async def send_approval_request_email( to_email: str, post_id: UUID, post_title: str, @@ -128,8 +129,8 @@ def send_approval_request_email( image_url: Optional[str] = None ) -> bool: """Send email to customer requesting approval of a post.""" - approve_token = generate_token(post_id, "approve") - reject_token = generate_token(post_id, "reject") + approve_token = await generate_token(post_id, "approve") + reject_token = await generate_token(post_id, "reject") approve_url = f"{base_url}/api/email-action/{approve_token}" reject_url = f"{base_url}/api/email-action/{reject_token}" diff --git a/src/services/moco_service.py b/src/services/moco_service.py new file mode 100644 index 0000000..e4b1168 --- /dev/null +++ b/src/services/moco_service.py @@ -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"

{greeting},

" + "

vielen Dank für Ihre Anfrage. Anbei finden Sie ein ausgearbeitetes Angebot " + "für die von Ihnen angefragten Leistungen.

" + ) + + +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 tag causes MOCO + to override the link text with its own '[identifier] Angebot' format. + """ + return ( + "

Wir freuen uns über Ihre Bestätigung per E-Mail oder direkt über diesen Link: " + "{customer_approval_link}

" + "

Freundliche Grüße,
" + "Olivia Kibele
" + "olivia.kibele@onyva.de
" + "0751 18523411

" + ) + + +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 diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py index 013346e..9f11e8e 100644 --- a/src/services/scheduler_service.py +++ b/src/services/scheduler_service.py @@ -54,14 +54,44 @@ class SchedulerService: async def _run_loop(self): """Main scheduler loop.""" + _tick = 0 while self._running: try: await self._process_due_posts() except Exception as e: logger.error(f"Scheduler error: {e}") + # Daily jobs (every 1440 ticks = 24h at 60s interval) + if _tick % 1440 == 0: + try: + await self._refresh_expiring_tokens() + except Exception as e: + logger.error(f"Token refresh job error: {e}") + try: + await self.db.cleanup_expired_email_tokens() + except Exception as e: + logger.error(f"Email token cleanup error: {e}") + + _tick += 1 await asyncio.sleep(self.check_interval) + async def _refresh_expiring_tokens(self): + """Proactively refresh LinkedIn tokens expiring within 7 days.""" + from src.services.linkedin_service import linkedin_service + accounts = await self.db.get_expiring_linkedin_accounts(within_days=7) + if not accounts: + return + logger.info(f"Refreshing {len(accounts)} expiring LinkedIn tokens") + for account in accounts: + try: + refreshed = await linkedin_service.refresh_access_token(account.id) + if refreshed: + logger.info(f"Token refresh ok: {account.id}") + else: + logger.warning(f"Token refresh failed: {account.id}") + except Exception as e: + logger.error(f"Token refresh error {account.id}: {e}") + async def _process_due_posts(self): """Process all posts that are due for publishing.""" due_posts = await self.db.get_scheduled_posts_due() diff --git a/src/web/admin/routes.py b/src/web/admin/routes.py index f5a4b47..2c60f7f 100644 --- a/src/web/admin/routes.py +++ b/src/web/admin/routes.py @@ -1,9 +1,9 @@ """Admin panel routes (password-protected) - User Management & Statistics.""" +import asyncio import re import secrets import math from datetime import datetime, timedelta -from io import BytesIO from pathlib import Path from typing import Optional from uuid import UUID @@ -14,19 +14,13 @@ from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from loguru import logger -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.units import cm -from reportlab.lib import colors -from reportlab.lib.pagesizes import A4 - from src.database import db from src.web.admin.auth import ( WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth ) from src.web.user.auth import UserSession, set_user_session -from src.services.email_service import send_email_with_attachment from src.config import settings +from src.services import moco_service # Router with /admin prefix admin_router = APIRouter(prefix="/admin", tags=["admin"]) @@ -534,10 +528,20 @@ async def license_keys_page(request: Request): try: keys = await db.list_license_keys() + # Load offers for all keys in parallel + offers_lists = await asyncio.gather( + *[db.list_license_key_offers(k.id) for k in keys], + return_exceptions=True + ) + offers_by_key = { + str(k.id): (v if not isinstance(v, Exception) else []) + for k, v in zip(keys, offers_lists) + } return templates.TemplateResponse("license_keys.html", { "request": request, "page": "license_keys", "keys": keys, + "offers_by_key": offers_by_key, "total_keys": len(keys), "used_keys": len([k for k in keys if k.used]), "available_keys": len([k for k in keys if not k.used]) @@ -626,255 +630,33 @@ async def delete_license_key_route(request: Request, key_id: str): raise HTTPException(status_code=500, detail=str(e)) -# ==================== LICENSE KEY OFFER ==================== - -AVG_TOKENS_PER_POST = 50_000 -SERVER_COST_EUR = 16.0 -SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer -API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR +# ==================== LICENSE KEY OFFER (MOCO) ==================== -def _fmt_eur(amount: float) -> str: - """Format a float as German EUR string, e.g. 1.250,00 €""" - return f"{amount:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".") +@admin_router.get("/api/moco/companies") +async def search_moco_companies_endpoint(request: Request, term: str = ""): + """Live-search MOCO companies by name.""" + if not verify_auth(request): + raise HTTPException(status_code=401) + + try: + companies = await moco_service.search_moco_companies(term) + return JSONResponse(companies) + except Exception as e: + logger.error(f"MOCO company search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) -def _build_offer_pdf( - key_description: str, - max_employees: int, - daily_token_limit: Optional[int], - price: float, - payment_frequency: str, - recipient_email: str, -) -> bytes: - """Generate a professional, simple PDF offer with logo.""" - buffer = BytesIO() - - PAGE_W, PAGE_H = A4 - MARGIN = 1.8 * cm - - doc = SimpleDocTemplate( - buffer, - pagesize=A4, - rightMargin=MARGIN, - leftMargin=MARGIN, - topMargin=MARGIN, - bottomMargin=MARGIN, - ) - - # ── Colors ──────────────────────────────────────────────────────────────── - C_DARK = colors.HexColor("#2d3838") - C_HIGHLIGHT = colors.HexColor("#ffc700") - C_GRAY = colors.HexColor("#666666") - C_LGRAY = colors.HexColor("#f5f5f5") - C_RULE = colors.HexColor("#e0e0e0") - C_WHITE = colors.white - CONTENT_W = PAGE_W - 2 * MARGIN - HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid - - # ── Styles ──────────────────────────────────────────────────────────────── - s = getSampleStyleSheet() - def ps(name, **kw): - return ParagraphStyle(name, parent=s["Normal"], **kw) - - s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14) - s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12) - s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13) - s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold") - s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold", - spaceBefore=0, spaceAfter=0, letterSpacing=0.8) - s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold") - s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold") - s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11) - - # ── Computed values ─────────────────────────────────────────────────────── - today = datetime.utcnow().strftime("%d.%m.%Y") - validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y") - offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}" - plan_name = key_description or "Standard Plan" - price_str = _fmt_eur(price) - freq_cap = payment_frequency.capitalize() - - if daily_token_limit: - posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST) - token_str = ( - f"{daily_token_limit:,}".replace(",", ".") + - f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)" - ) - else: - token_str = "Unbegrenzt" - - story = [] - - # ── Header: Logo + ANGEBOT ──────────────────────────────────────────────── - logo_path = Path(__file__).parent.parent / "static" / "logo.png" - logo = ( - Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional") - if logo_path.exists() - else Paragraph("Onyva", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold")) - ) - hdr = Table( - [[ - logo, - [ - Paragraph('ANGEBOT', - ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)), - Spacer(1, 0.1 * cm), - Paragraph(f'{offer_number}', - 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 " - "Onyva LinkedIn Content Automation 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"Mitarbeiter: {max_employees} Nutzer", s_body), - Paragraph(f"KI-Token-Limit: {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'✓ {name}
' - f' {desc}', - 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: team@onyva.de", - s_small, - )) - story.append(Spacer(1, 0.35 * cm)) - story.append(Paragraph("Mit freundlichen Grüßen,
Onyva", s_body)) - - doc.build(story) - return buffer.getvalue() - - -@admin_router.post("/api/license-keys/{key_id}/send-offer") -async def send_license_offer( +@admin_router.post("/api/license-keys/{key_id}/create-offer") +async def create_moco_offer_endpoint( request: Request, key_id: str, - email: str = Form(...), + company_id: int = Form(...), + company_name: str = Form(...), price: float = Form(...), payment_frequency: str = Form(...), ): - """Generate a PDF offer and send it via email.""" + """Create an offer in MOCO for the given license key.""" if not verify_auth(request): raise HTTPException(status_code=401) @@ -883,74 +665,66 @@ async def send_license_offer( if not key: raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden") - pdf_bytes = _build_offer_pdf( - key_description=key.description or "", - max_employees=key.max_employees, - daily_token_limit=key.daily_token_limit, + plan_name = key.description or "Standard Plan" + offer = await moco_service.create_moco_offer( + company_id=company_id, + company_name=company_name, price=price, payment_frequency=payment_frequency, - recipient_email=email, + plan_name=plan_name, + max_employees=key.max_employees, + daily_token_limit=key.daily_token_limit, ) - posts_note = "" - if key.daily_token_limit: - posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST) - token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)" - else: - token_str = "Unbegrenzt" + offer_url = offer.get("web_url") or offer.get("url") or f"https://{settings.moco_domain}.mocoapp.com/offers/{offer.get('id', '')}" - price_str = f"{price:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".") - plan_name = key.description or "Standard Plan" - - html_body = f""" -
-
-

LinkedIn Content Automation

-

Ihr persönliches Angebot

-
-
-

Sehr geehrte Damen und Herren,

-

- vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.
- Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument. -

-
-

Angebotsübersicht

- - - - - - -
Plan{plan_name}
Mitarbeiter{key.max_employees} Nutzer
Token-Limit{token_str}
Preis{price_str}
Zahlungsweise{payment_frequency.capitalize()}
-
-

Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.

-

Bei Fragen stehen wir Ihnen gerne zur Verfügung.

-

Mit freundlichen Grüßen,
Onyva

-
-
-

Onyva • team@onyva.de

-
-
- """ - - offer_number = datetime.utcnow().strftime('%Y%m%d%H%M') - filename = f"Angebot_{offer_number}.pdf" - success = send_email_with_attachment( - to_email=email, - subject=f"Ihr Angebot – LinkedIn Content Automation ({plan_name})", - html_content=html_body, - attachment_bytes=pdf_bytes, - attachment_filename=filename, + # Persist offer in DB + await db.create_license_key_offer( + license_key_id=UUID(key_id), + moco_offer_id=offer["id"], + moco_offer_identifier=offer.get("identifier"), + moco_offer_url=offer_url, + offer_title=offer.get("title") or f"LinkedIn Content Automation – {plan_name}", + company_name=company_name, + price=price, + payment_frequency=payment_frequency, ) - if not success: - raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden. SMTP prüfen.") - - return JSONResponse({"success": True, "message": f"Angebot erfolgreich an {email} gesendet."}) + return JSONResponse({"success": True, "offer_url": offer_url}) except HTTPException: raise except Exception as e: - logger.error(f"Error sending license offer: {e}") + logger.error(f"Error creating MOCO offer: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@admin_router.post("/api/license-key-offers/{offer_id}/send") +async def send_license_key_offer(request: Request, offer_id: str): + """Send a stored MOCO offer via email and mark it as sent.""" + if not verify_auth(request): + raise HTTPException(status_code=401) + + try: + from src.database.models import LicenseKeyOffer + result = await asyncio.to_thread( + lambda: db.client.table("license_key_offers") + .select("*") + .eq("id", offer_id) + .execute() + ) + if not result.data: + raise HTTPException(status_code=404, detail="Angebot nicht gefunden") + + stored = LicenseKeyOffer(**result.data[0]) + await moco_service.send_moco_offer(stored.moco_offer_id) + await db.update_license_key_offer_status(UUID(offer_id), "sent") + + return JSONResponse({"success": True}) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error sending offer {offer_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + diff --git a/src/web/templates/admin/license_keys.html b/src/web/templates/admin/license_keys.html index 566d87a..7cbe20c 100644 --- a/src/web/templates/admin/license_keys.html +++ b/src/web/templates/admin/license_keys.html @@ -52,6 +52,7 @@ {% for key in keys %} + {% set key_offers = offers_by_key.get(key.id | string, []) %}
{{ key.key }}
@@ -67,44 +68,42 @@ {% if key.used %} - - Verwendet - + Verwendet {% else %} - - Verfügbar - + Verfügbar {% endif %} {{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }} + {% if key_offers %} + + {% endif %} {% if not key.used %}
+ {% endif %} + + + {% endfor %} + + + + {% endif %} {% endfor %} @@ -166,13 +200,14 @@