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
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.
-
| 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
-Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.
+Erstellt ein Angebot direkt in MOCO. Versand erfolgt manuell in MOCO.
Das Angebot wird per E-Mail an die hinterlegten Empfänger in MOCO gesendet.
+ +