Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
366 lines
12 KiB
Python
366 lines
12 KiB
Python
"""
|
|
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,
|
|
)
|
|
|