Spaces:
Sleeping
Sleeping
| """Analytics events router (event ingestion + aggregated summary). | |
| Uses asyncpg pool from app.db.asyncpg_pool. | |
| """ | |
| from typing import Any | |
| import asyncpg | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from app.db.asyncpg_pool import get_asyncpg_conn | |
| from app.auth_deps import get_current_admin | |
| from app import live_tracking | |
| from fastapi import WebSocket, WebSocketDisconnect, Query, Header | |
| import jwt as _jwt | |
| import os as _os | |
| from datetime import datetime, timezone | |
| from app.schemas.shop import AnalyticsEvent | |
| router = APIRouter() | |
| async def ingest_event( | |
| event: AnalyticsEvent, | |
| conn: asyncpg.Connection = Depends(get_asyncpg_conn), | |
| ): | |
| """Receive a single analytics event and broadcast it to live subscribers.""" | |
| # Parse the incoming ISO-8601 timestamp into a real datetime; asyncpg | |
| # rejects strings for timestamptz columns even with a cast in the SQL. | |
| raw_ts = event.timestamp | |
| try: | |
| # JS toISOString() emits e.g. "2026-05-09T14:48:44.355Z" | |
| ts_dt = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) | |
| except (ValueError, AttributeError): | |
| ts_dt = datetime.now(timezone.utc) | |
| await conn.execute( | |
| """ | |
| INSERT INTO analytics_events | |
| (session_id, user_id, event_type, page, element, value, monetary_value, timestamp) | |
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | |
| """, | |
| event.session_id, | |
| event.user_id, | |
| event.event_type, | |
| event.page, | |
| event.element, | |
| event.value, | |
| event.monetary_value, | |
| ts_dt, | |
| ) | |
| # Broadcast to live subscribers (admin dashboards listening on /ws/analytics). | |
| await live_tracking.publish({ | |
| "session_id": event.session_id, | |
| "user_id": event.user_id, | |
| "event_type": event.event_type, | |
| "page": event.page, | |
| "element": event.element, | |
| "value": event.value, | |
| "monetary_value": event.monetary_value, | |
| "timestamp": event.timestamp, | |
| }) | |
| return {"ok": True} | |
| async def get_summary( | |
| conn: asyncpg.Connection = Depends(get_asyncpg_conn), | |
| _admin: int = Depends(get_current_admin), | |
| ) -> dict[str, Any]: | |
| """Return aggregated analytics data.""" | |
| total_events: int = await conn.fetchval( | |
| "SELECT COUNT(*) FROM analytics_events" | |
| ) | |
| unique_sessions: int = await conn.fetchval( | |
| "SELECT COUNT(DISTINCT session_id) FROM analytics_events" | |
| ) | |
| page_views: int = await conn.fetchval( | |
| "SELECT COUNT(*) FROM analytics_events WHERE event_type = 'page_view'" | |
| ) | |
| rows = await conn.fetch( | |
| """ | |
| SELECT page, COUNT(*) AS views | |
| FROM analytics_events | |
| WHERE event_type = 'page_view' | |
| GROUP BY page | |
| ORDER BY views DESC | |
| LIMIT 5 | |
| """ | |
| ) | |
| top_pages = [{"page": row["page"], "views": row["views"]} for row in rows] | |
| rows = await conn.fetch( | |
| """ | |
| SELECT element, COUNT(*) AS cnt | |
| FROM analytics_events | |
| WHERE event_type = 'click' AND element IS NOT NULL | |
| GROUP BY element | |
| ORDER BY cnt DESC | |
| LIMIT 10 | |
| """ | |
| ) | |
| top_elements = [{"element": row["element"], "count": row["cnt"]} for row in rows] | |
| rows = await conn.fetch( | |
| """ | |
| SELECT event_type, COUNT(*) AS cnt | |
| FROM analytics_events | |
| GROUP BY event_type | |
| ORDER BY cnt DESC | |
| """ | |
| ) | |
| event_types = [{"event_type": r["event_type"], "count": r["cnt"]} for r in rows] | |
| rows = await conn.fetch( | |
| """ | |
| SELECT date_trunc('hour', timestamp) AS hour, | |
| COUNT(*) AS count | |
| FROM analytics_events | |
| WHERE timestamp >= NOW() - INTERVAL '48 hours' | |
| GROUP BY hour | |
| ORDER BY hour | |
| """ | |
| ) | |
| hourly_trend = [ | |
| {"hour": r["hour"].isoformat(), "count": r["count"]} for r in rows | |
| ] | |
| funnel_steps = ["page_view", "add_to_cart", "checkout_start", "order_placed"] | |
| funnel: dict[str, int] = {} | |
| for step in funnel_steps: | |
| funnel[step] = await conn.fetchval( | |
| "SELECT COUNT(*) FROM analytics_events WHERE event_type = $1", step | |
| ) | |
| rows = await conn.fetch( | |
| """ | |
| SELECT value, COUNT(*) AS cnt | |
| FROM analytics_events | |
| WHERE element = 'search_input' | |
| AND event_type = 'keypress' | |
| AND value IS NOT NULL | |
| GROUP BY value | |
| ORDER BY cnt DESC | |
| LIMIT 10 | |
| """ | |
| ) | |
| top_searches: dict[str, int] = {r["value"]: r["cnt"] for r in rows} | |
| return { | |
| "total_events": total_events, | |
| "unique_sessions": unique_sessions, | |
| "page_views": page_views, | |
| "top_elements": top_elements, | |
| "top_pages": top_pages, | |
| "event_breakdown": event_types, | |
| "hourly_trend": hourly_trend, | |
| "top_searches": top_searches, | |
| "funnel": funnel, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Live RFM segmentation | |
| # --------------------------------------------------------------------------- | |
| # | |
| # Computes Recency / Frequency / Monetary directly from the orders table | |
| # (and optionally cross-references analytics_events for engagement signal), | |
| # then assigns each user a 1-5 quintile score on each axis and a named | |
| # segment. This is a real-time alternative to ThreadHouse's batch CSV upload | |
| # pipeline: useful while traffic is still small. | |
| # --------------------------------------------------------------------------- | |
| def _quintile(values: list[float], v: float, reverse: bool = False) -> int: | |
| """ | |
| Map `v` to a 1-5 score based on its position in the sorted `values` list. | |
| If reverse=True, lower `v` -> higher score (used for Recency: smaller is better). | |
| """ | |
| if not values: | |
| return 1 | |
| sorted_vals = sorted(values) | |
| n = len(sorted_vals) | |
| # rank in [0, n-1] | |
| rank = sum(1 for x in sorted_vals if x < v) | |
| pct = rank / max(n - 1, 1) | |
| if reverse: | |
| pct = 1 - pct | |
| if pct >= 0.8: | |
| return 5 | |
| if pct >= 0.6: | |
| return 4 | |
| if pct >= 0.4: | |
| return 3 | |
| if pct >= 0.2: | |
| return 2 | |
| return 1 | |
| def _label(r: int, f: int, m: int) -> str: | |
| """Standard 11-segment RFM mapping inspired by Putler/Optimove.""" | |
| score = (r, f, m) | |
| avg_fm = (f + m) / 2 | |
| if r >= 4 and avg_fm >= 4: | |
| return "Champions" | |
| if r >= 3 and avg_fm >= 3: | |
| return "Loyal Customers" | |
| if r >= 4 and avg_fm <= 2: | |
| return "New Customers" | |
| if r >= 3 and 2 <= avg_fm <= 3: | |
| return "Potential Loyalists" | |
| if r >= 4 and avg_fm == 1: | |
| return "Promising" | |
| if 2 <= r <= 3 and 2 <= avg_fm <= 3: | |
| return "Need Attention" | |
| if r == 2 and avg_fm <= 2: | |
| return "About to Sleep" | |
| if r <= 2 and avg_fm >= 4: | |
| return "At Risk" | |
| if r == 1 and avg_fm >= 4: | |
| return "Cant Lose Them" | |
| if r <= 2 and 2 <= avg_fm <= 3: | |
| return "Hibernating" | |
| return "Lost" | |
| async def live_segmentation( | |
| conn: asyncpg.Connection = Depends(get_asyncpg_conn), | |
| _admin: int = Depends(get_current_admin), | |
| ) -> dict: | |
| """ | |
| Compute RFM scores and segments for every user with at least one order. | |
| Returns: | |
| - users: list of {user_id, email, name, recency_days, frequency, | |
| monetary, r, f, m, segment, last_order_at} | |
| - segment_counts: {segment_name: count} | |
| - total_segmented_users: int | |
| """ | |
| rows = await conn.fetch( | |
| """ | |
| SELECT | |
| u.id AS user_id, | |
| u.email AS email, | |
| u.name AS name, | |
| COUNT(o.id) AS frequency, | |
| COALESCE(SUM(o.total), 0) AS monetary, | |
| MAX(o.created_at) AS last_order_at, | |
| EXTRACT(DAY FROM NOW() - MAX(o.created_at))::int AS recency_days | |
| FROM users u | |
| JOIN orders o ON o.user_id = u.id | |
| GROUP BY u.id, u.email, u.name | |
| """ | |
| ) | |
| if not rows: | |
| return { | |
| "users": [], | |
| "segment_counts": {}, | |
| "total_segmented_users": 0, | |
| "note": "No users have placed orders yet.", | |
| } | |
| recencies = [int(r["recency_days"]) for r in rows] | |
| frequencies = [int(r["frequency"]) for r in rows] | |
| monetaries = [float(r["monetary"]) for r in rows] | |
| users = [] | |
| segment_counts: dict[str, int] = {} | |
| for r in rows: | |
| recency_days = int(r["recency_days"]) | |
| frequency = int(r["frequency"]) | |
| monetary = float(r["monetary"]) | |
| r_score = _quintile(recencies, recency_days, reverse=True) # smaller days -> higher score | |
| f_score = _quintile(frequencies, frequency) | |
| m_score = _quintile(monetaries, monetary) | |
| segment = _label(r_score, f_score, m_score) | |
| segment_counts[segment] = segment_counts.get(segment, 0) + 1 | |
| users.append({ | |
| "user_id": r["user_id"], | |
| "email": r["email"], | |
| "name": r["name"], | |
| "recency_days": recency_days, | |
| "frequency": frequency, | |
| "monetary": round(monetary, 2), | |
| "r": r_score, | |
| "f": f_score, | |
| "m": m_score, | |
| "rfm": f"{r_score}{f_score}{m_score}", | |
| "segment": segment, | |
| "last_order_at": r["last_order_at"].isoformat() if r["last_order_at"] else None, | |
| }) | |
| users.sort(key=lambda u: u["monetary"], reverse=True) | |
| return { | |
| "total_segmented_users": len(users), | |
| "segment_counts": segment_counts, | |
| "users": users, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Live tracking | |
| # --------------------------------------------------------------------------- | |
| async def live_snapshot( | |
| minutes: int = Query(5, ge=1, le=60, description="Look-back window in minutes"), | |
| conn: asyncpg.Connection = Depends(get_asyncpg_conn), | |
| _admin: int = Depends(get_current_admin), | |
| ) -> dict: | |
| """ | |
| Snapshot of activity in the last `minutes` minutes: | |
| - active_sessions: unique session_ids | |
| - events_per_min: recent events broken down by minute | |
| - top_pages: most-viewed pages right now | |
| - recent_events: last 50 events (newest first) | |
| """ | |
| since_clause = f"timestamp >= NOW() - INTERVAL '{minutes} minutes'" | |
| active_sessions = await conn.fetchval( | |
| f"SELECT COUNT(DISTINCT session_id) FROM analytics_events WHERE {since_clause}" | |
| ) | |
| total_events = await conn.fetchval( | |
| f"SELECT COUNT(*) FROM analytics_events WHERE {since_clause}" | |
| ) | |
| rows = await conn.fetch( | |
| f""" | |
| SELECT date_trunc('minute', timestamp) AS minute, COUNT(*) AS count | |
| FROM analytics_events | |
| WHERE {since_clause} | |
| GROUP BY minute ORDER BY minute | |
| """ | |
| ) | |
| events_per_min = [ | |
| {"minute": r["minute"].isoformat(), "count": r["count"]} for r in rows | |
| ] | |
| rows = await conn.fetch( | |
| f""" | |
| SELECT page, COUNT(*) AS views | |
| FROM analytics_events | |
| WHERE {since_clause} AND event_type = 'page_view' AND page IS NOT NULL | |
| GROUP BY page ORDER BY views DESC LIMIT 5 | |
| """ | |
| ) | |
| top_pages = [{"page": r["page"], "views": r["views"]} for r in rows] | |
| rows = await conn.fetch( | |
| f""" | |
| SELECT session_id, event_type, page, element, value, timestamp | |
| FROM analytics_events | |
| WHERE {since_clause} | |
| ORDER BY timestamp DESC LIMIT 50 | |
| """ | |
| ) | |
| recent = [ | |
| { | |
| "session_id": r["session_id"], | |
| "event_type": r["event_type"], | |
| "page": r["page"], | |
| "element": r["element"], | |
| "value": r["value"], | |
| "timestamp": r["timestamp"].isoformat() if r["timestamp"] else None, | |
| } | |
| for r in rows | |
| ] | |
| return { | |
| "window_minutes": minutes, | |
| "active_sessions": int(active_sessions or 0), | |
| "total_events": int(total_events or 0), | |
| "events_per_minute": events_per_min, | |
| "top_pages": top_pages, | |
| "recent_events": recent, | |
| "live_subscribers": live_tracking.subscriber_count(), | |
| } | |
| async def live_event_stream(websocket: WebSocket): | |
| """ | |
| Real-time stream of analytics events. Admin-only. | |
| Connect with: ws://host:8000/api/analytics/ws?token=<JWT> | |
| Each message is the same JSON the ingest endpoint received. | |
| """ | |
| # ----- token check (admin only) ----- | |
| token = websocket.query_params.get("token") | |
| if not token: | |
| await websocket.close(code=4401) | |
| return | |
| try: | |
| payload = _jwt.decode(token, _os.getenv("JWT_SECRET", ""), algorithms=["HS256"]) | |
| user_id = int(payload["sub"]) | |
| except Exception: | |
| await websocket.close(code=4401) | |
| return | |
| # Verify role from DB | |
| from app.db.asyncpg_pool import _pool # lazily reuse the global pool | |
| if _pool is None: | |
| await websocket.close(code=1011) | |
| return | |
| async with _pool.acquire() as conn: | |
| role = await conn.fetchval("SELECT role FROM users WHERE id = $1", user_id) | |
| if role != "admin": | |
| await websocket.close(code=4403) | |
| return | |
| await websocket.accept() | |
| queue = live_tracking.subscribe() | |
| try: | |
| await websocket.send_json({"type": "hello", "message": "Live stream open"}) | |
| while True: | |
| event = await queue.get() | |
| await websocket.send_json({"type": "event", "data": event}) | |
| except WebSocketDisconnect: | |
| pass | |
| finally: | |
| live_tracking.unsubscribe(queue) | |
| async def customer_detail( | |
| user_id: int, | |
| conn: asyncpg.Connection = Depends(get_asyncpg_conn), | |
| _admin: int = Depends(get_current_admin), | |
| ) -> dict: | |
| """Per-user drilldown: profile + orders + recent events.""" | |
| user = await conn.fetchrow( | |
| "SELECT id, name, email, role, created_at FROM users WHERE id = $1", user_id | |
| ) | |
| if not user: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| orders = await conn.fetch( | |
| "SELECT order_id, status, total, payment_method, created_at FROM orders " | |
| "WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50", | |
| user_id, | |
| ) | |
| events = await conn.fetch( | |
| "SELECT event_type, page, element, value, monetary_value, timestamp " | |
| "FROM analytics_events WHERE user_id = $1 ORDER BY timestamp DESC LIMIT 100", | |
| user_id, | |
| ) | |
| total_spent = sum(float(o["total"]) for o in orders) | |
| return { | |
| "user": { | |
| "id": user["id"], "name": user["name"], "email": user["email"], | |
| "role": user["role"], | |
| "joined_at": user["created_at"].isoformat() if user["created_at"] else None, | |
| }, | |
| "stats": { | |
| "order_count": len(orders), | |
| "total_spent": round(total_spent, 2), | |
| "event_count": len(events), | |
| }, | |
| "orders": [ | |
| { | |
| "order_id": o["order_id"], | |
| "status": o["status"], | |
| "total": float(o["total"]), | |
| "payment_method": o["payment_method"], | |
| "created_at": o["created_at"].isoformat() if o["created_at"] else None, | |
| } for o in orders | |
| ], | |
| "events": [ | |
| { | |
| "event_type": e["event_type"], | |
| "page": e["page"], | |
| "element": e["element"], | |
| "value": e["value"], | |
| "monetary_value": float(e["monetary_value"]) if e["monetary_value"] is not None else None, | |
| "timestamp": e["timestamp"].isoformat() if e["timestamp"] else None, | |
| } for e in events | |
| ], | |
| } | |