""" Telemetry KPIs (4A.2). Admin-only endpoint to compute funnel KPIs from telemetry_events. """ from __future__ import annotations import json import statistics from datetime import datetime, timedelta from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.models.telemetry import TelemetryEvent from app.models.user import User from app.schemas.referrals import ReferralKpiWindow, ReferralKpisResponse, ReferralReferrerRow from app.schemas.telemetry import ( DealFunnelKpis, TelemetryKpiWindow, TelemetryKpisResponse, YieldFunnelKpis, ) router = APIRouter(prefix="/telemetry", tags=["telemetry"]) def _require_admin(user: User) -> None: if not user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") def _safe_json(metadata_json: Optional[str]) -> dict[str, Any]: if not metadata_json: return {} try: value = json.loads(metadata_json) return value if isinstance(value, dict) else {} except Exception: return {} def _median(values: list[float]) -> Optional[float]: if not values: return None return float(statistics.median(values)) @router.get("/kpis", response_model=TelemetryKpisResponse) async def get_kpis( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _require_admin(current_user) end = datetime.utcnow() start = end - timedelta(days=days) event_names = [ # Deal funnel "listing_view", "inquiry_created", "inquiry_status_changed", "message_sent", "listing_marked_sold", # Yield funnel "yield_connected", "yield_click", "yield_conversion", "payout_paid", ] rows = ( await db.execute( select( TelemetryEvent.event_name, TelemetryEvent.created_at, TelemetryEvent.listing_id, TelemetryEvent.inquiry_id, TelemetryEvent.yield_domain_id, TelemetryEvent.click_id, TelemetryEvent.metadata_json, ).where( and_( TelemetryEvent.created_at >= start, TelemetryEvent.created_at <= end, TelemetryEvent.event_name.in_(event_names), ) ) ) ).all() # ----------------------------- # Deal KPIs # ----------------------------- listing_views = 0 inquiries_created = 0 inquiry_created_at: dict[int, datetime] = {} first_seller_reply_at: dict[int, datetime] = {} listings_with_inquiries: set[int] = set() sold_listings: set[int] = set() sold_at_by_listing: dict[int, datetime] = {} first_inquiry_at_by_listing: dict[int, datetime] = {} # ----------------------------- # Yield KPIs # ----------------------------- connected_domains = 0 clicks = 0 conversions = 0 payouts_paid = 0 payouts_paid_amount_total = 0.0 for event_name, created_at, listing_id, inquiry_id, yield_domain_id, click_id, metadata_json in rows: created_at = created_at # already datetime if event_name == "listing_view": listing_views += 1 continue if event_name == "inquiry_created": inquiries_created += 1 if inquiry_id: inquiry_created_at[inquiry_id] = created_at if listing_id: listings_with_inquiries.add(listing_id) prev = first_inquiry_at_by_listing.get(listing_id) if prev is None or created_at < prev: first_inquiry_at_by_listing[listing_id] = created_at continue if event_name == "message_sent": if not inquiry_id: continue meta = _safe_json(metadata_json) if meta.get("role") == "seller": prev = first_seller_reply_at.get(inquiry_id) if prev is None or created_at < prev: first_seller_reply_at[inquiry_id] = created_at continue if event_name == "listing_marked_sold": if listing_id: sold_listings.add(listing_id) sold_at_by_listing[listing_id] = created_at continue if event_name == "yield_connected": connected_domains += 1 continue if event_name == "yield_click": clicks += 1 continue if event_name == "yield_conversion": conversions += 1 continue if event_name == "payout_paid": payouts_paid += 1 meta = _safe_json(metadata_json) amount = meta.get("amount") if isinstance(amount, (int, float)): payouts_paid_amount_total += float(amount) continue seller_replied_inquiries = len(first_seller_reply_at.keys()) inquiry_reply_rate = (seller_replied_inquiries / inquiries_created) if inquiries_created else 0.0 # Inquiry → Sold rate (on listing-level intersection) sold_from_inquiry = sold_listings.intersection(listings_with_inquiries) inquiry_to_sold_listing_rate = (len(sold_from_inquiry) / len(listings_with_inquiries)) if listings_with_inquiries else 0.0 # Median reply time (seconds): inquiry_created → first seller message reply_deltas: list[float] = [] for inq_id, created in inquiry_created_at.items(): replied = first_seller_reply_at.get(inq_id) if replied: reply_deltas.append((replied - created).total_seconds()) # Median time-to-sold (seconds): first inquiry on listing → listing sold sold_deltas: list[float] = [] for listing in sold_from_inquiry: inq_at = first_inquiry_at_by_listing.get(listing) sold_at = sold_at_by_listing.get(listing) if inq_at and sold_at and sold_at >= inq_at: sold_deltas.append((sold_at - inq_at).total_seconds()) deal = DealFunnelKpis( listing_views=listing_views, inquiries_created=inquiries_created, seller_replied_inquiries=seller_replied_inquiries, inquiry_reply_rate=float(inquiry_reply_rate), listings_with_inquiries=len(listings_with_inquiries), listings_sold=len(sold_listings), inquiry_to_sold_listing_rate=float(inquiry_to_sold_listing_rate), median_reply_seconds=_median(reply_deltas), median_time_to_sold_seconds=_median(sold_deltas), ) yield_kpis = YieldFunnelKpis( connected_domains=connected_domains, clicks=clicks, conversions=conversions, conversion_rate=float(conversions / clicks) if clicks else 0.0, payouts_paid=payouts_paid, payouts_paid_amount_total=float(payouts_paid_amount_total), ) return TelemetryKpisResponse( window=TelemetryKpiWindow(days=days, start=start, end=end), deal=deal, yield_=yield_kpis, ) @router.get("/referrals", response_model=ReferralKpisResponse) async def get_referral_kpis( days: int = Query(30, ge=1, le=365), limit: int = Query(200, ge=1, le=1000), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Admin-only referral KPIs for the viral loop (3C.2). This is intentionally user-based (users.referred_by_user_id) + telemetry-based (referral_link_viewed), so it stays robust even if ref codes evolve. """ _require_admin(current_user) end = datetime.utcnow() start = end - timedelta(days=days) # Referred user counts per referrer (all-time + window) referred_counts_subq = ( select( User.referred_by_user_id.label("referrer_user_id"), func.count(User.id).label("referred_users_total"), func.coalesce( func.sum(case((User.created_at >= start, 1), else_=0)), 0, ).label("referred_users_window"), ) .where(User.referred_by_user_id.isnot(None)) .group_by(User.referred_by_user_id) .subquery() ) # Referral link views in window (telemetry) link_views_subq = ( select( TelemetryEvent.user_id.label("referrer_user_id"), func.count(TelemetryEvent.id).label("referral_link_views_window"), ) .where( and_( TelemetryEvent.event_name == "referral_link_viewed", TelemetryEvent.created_at >= start, TelemetryEvent.created_at <= end, TelemetryEvent.user_id.isnot(None), ) ) .group_by(TelemetryEvent.user_id) .subquery() ) # Referrers: anyone with an invite_code (we still show even if counts are zero) rows = ( await db.execute( select( User.id, User.email, User.invite_code, User.created_at, func.coalesce(referred_counts_subq.c.referred_users_total, 0), func.coalesce(referred_counts_subq.c.referred_users_window, 0), func.coalesce(link_views_subq.c.referral_link_views_window, 0), ) .where(User.invite_code.isnot(None)) .outerjoin(referred_counts_subq, referred_counts_subq.c.referrer_user_id == User.id) .outerjoin(link_views_subq, link_views_subq.c.referrer_user_id == User.id) .order_by( func.coalesce(referred_counts_subq.c.referred_users_window, 0).desc(), func.coalesce(referred_counts_subq.c.referred_users_total, 0).desc(), User.created_at.desc(), ) .offset(offset) .limit(limit) ) ).all() referrers = [ ReferralReferrerRow( user_id=int(user_id), email=str(email), invite_code=str(invite_code) if invite_code else None, created_at=created_at, referred_users_total=int(referred_total or 0), referred_users_window=int(referred_window or 0), referral_link_views_window=int(link_views or 0), ) for user_id, email, invite_code, created_at, referred_total, referred_window, link_views in rows ] totals = {} totals["referrers_with_invite_code"] = int( ( await db.execute( select(func.count(User.id)).where(User.invite_code.isnot(None)) ) ).scalar() or 0 ) totals["referred_users_total"] = int( ( await db.execute( select(func.count(User.id)).where(User.referred_by_user_id.isnot(None)) ) ).scalar() or 0 ) totals["referred_users_window"] = int( ( await db.execute( select(func.count(User.id)).where( and_( User.referred_by_user_id.isnot(None), User.created_at >= start, User.created_at <= end, ) ) ) ).scalar() or 0 ) totals["referral_link_views_window"] = int( ( await db.execute( select(func.count(TelemetryEvent.id)).where( and_( TelemetryEvent.event_name == "referral_link_viewed", TelemetryEvent.created_at >= start, TelemetryEvent.created_at <= end, ) ) ) ).scalar() or 0 ) return ReferralKpisResponse( window=ReferralKpiWindow(days=days, start=start, end=end), totals=totals, referrers=referrers, )