Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
115 lines
3.7 KiB
Python
115 lines
3.7 KiB
Python
"""Public Talk-to-Sales endpoint — no auth required.
|
|
|
|
POST /api/v1/sales-leads
|
|
- Inserts a sales_leads row.
|
|
- Fires (best-effort) a notification email to settings.SALES_LEAD_RECIPIENT_EMAIL.
|
|
- Emits a server-side PostHog event (best-effort).
|
|
- Rate-limited per IP (5/hour).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.admin_database import get_admin_db
|
|
from app.core.config import settings
|
|
from app.core.email import EmailService
|
|
from app.core.rate_limit import limiter
|
|
from app.models.sales_lead import SalesLead
|
|
from app.schemas.sales_lead import SalesLeadCreate, SalesLeadCreateResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/sales-leads", tags=["sales"])
|
|
|
|
|
|
async def _send_notification_email(lead: SalesLead) -> None:
|
|
"""Fire-and-forget wrapper. EmailService methods never raise, but we
|
|
still wrap in a try/except to defend against future regressions."""
|
|
try:
|
|
await EmailService.send_sales_lead_notification(
|
|
to_email=settings.SALES_LEAD_RECIPIENT_EMAIL,
|
|
lead=lead,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Sales lead notification email failed for lead %s",
|
|
lead.id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _capture_posthog_event(lead: SalesLead) -> None:
|
|
"""Emit `talk_to_sales_form_submitted` server-side. Best-effort.
|
|
|
|
Backend PostHog SDK isn't initialized in the project today; this function
|
|
is the single instrumentation point so wiring it up later is a one-line
|
|
change. The call is wrapped so any future failure can never fail the
|
|
request.
|
|
"""
|
|
try:
|
|
# Lazy import — keeps the dependency optional. When the backend
|
|
# PostHog client is wired in (likely as `app.core.analytics.posthog`),
|
|
# swap the import path here and the event will fire automatically.
|
|
try:
|
|
from app.core.analytics import posthog # type: ignore[attr-defined]
|
|
except ImportError:
|
|
logger.debug(
|
|
"PostHog server-side capture skipped — client not configured"
|
|
)
|
|
return
|
|
|
|
distinct_id = lead.posthog_distinct_id or f"sales_lead:{lead.id}"
|
|
posthog.capture(
|
|
distinct_id=distinct_id,
|
|
event="talk_to_sales_form_submitted",
|
|
properties={
|
|
"source": lead.source,
|
|
"company": lead.company,
|
|
"team_size": lead.team_size,
|
|
},
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"PostHog capture failed for sales lead %s",
|
|
lead.id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
@router.post("", response_model=SalesLeadCreateResponse, status_code=201)
|
|
@limiter.limit("5/hour")
|
|
async def create_sales_lead(
|
|
request: Request,
|
|
data: SalesLeadCreate,
|
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
|
) -> SalesLeadCreateResponse:
|
|
"""Public Talk-to-Sales submission.
|
|
|
|
Creates a sales_leads row, fires (best-effort) a notification email and a
|
|
server-side PostHog event. Rate-limited per IP at 5/hour.
|
|
"""
|
|
lead = SalesLead(
|
|
email=str(data.email).lower(),
|
|
name=data.name,
|
|
company=data.company,
|
|
team_size=data.team_size,
|
|
message=data.message,
|
|
source=data.source,
|
|
posthog_distinct_id=data.posthog_distinct_id,
|
|
)
|
|
db.add(lead)
|
|
await db.commit()
|
|
await db.refresh(lead)
|
|
|
|
# Fire-and-forget: email + analytics. Failures must not fail the request.
|
|
asyncio.create_task(_send_notification_email(lead))
|
|
_capture_posthog_event(lead)
|
|
|
|
return SalesLeadCreateResponse(id=lead.id, status="received")
|