From c29b56040133047088295d8455416c9af43878cf Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Tue, 31 Mar 2026 07:15:54 +0800
Subject: [PATCH] Add app analytics tracking for paywall and checkout events
---
frontend/app/api/analytics/events/route.ts | 45 +++++
.../app/api/ops/analytics/funnel/route.ts | 43 +++++
frontend/components/account/AccountCenter.tsx | 40 +++-
frontend/components/dashboard/DetailPanel.tsx | 10 +
.../dashboard/ProFeaturePaywall.tsx | 11 +-
frontend/hooks/useDashboardStore.tsx | 38 ++++
frontend/lib/app-analytics.ts | 91 +++++++++
frontend/lib/dashboard-types.ts | 1 +
frontend/middleware.ts | 1 +
src/database/db_manager.py | 175 ++++++++++++++++++
web/core.py | 7 +
web/routes.py | 46 +++++
12 files changed, 506 insertions(+), 2 deletions(-)
create mode 100644 frontend/app/api/analytics/events/route.ts
create mode 100644 frontend/app/api/ops/analytics/funnel/route.ts
create mode 100644 frontend/lib/app-analytics.ts
diff --git a/frontend/app/api/analytics/events/route.ts b/frontend/app/api/analytics/events/route.ts
new file mode 100644
index 00000000..73f8bb8c
--- /dev/null
+++ b/frontend/app/api/analytics/events/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ applyAuthResponseCookies,
+ buildBackendRequestHeaders,
+} from "@/lib/backend-auth";
+
+const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
+
+export async function POST(req: NextRequest) {
+ if (!API_BASE) {
+ return NextResponse.json(
+ { error: "POLYWEATHER_API_BASE_URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ try {
+ const body = await req.json();
+ const auth = await buildBackendRequestHeaders(req);
+ const headers = new Headers(auth.headers);
+ headers.set("Content-Type", "application/json");
+ const res = await fetch(`${API_BASE}/api/analytics/events`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body ?? {}),
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ const raw = await res.text();
+ const response = NextResponse.json(
+ { error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
+ { status: res.status },
+ );
+ return applyAuthResponseCookies(response, auth.response);
+ }
+ const data = await res.json();
+ const response = NextResponse.json(data);
+ return applyAuthResponseCookies(response, auth.response);
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Failed to track analytics event", detail: String(error) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/frontend/app/api/ops/analytics/funnel/route.ts b/frontend/app/api/ops/analytics/funnel/route.ts
new file mode 100644
index 00000000..0949e6e0
--- /dev/null
+++ b/frontend/app/api/ops/analytics/funnel/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ applyAuthResponseCookies,
+ buildBackendRequestHeaders,
+} from "@/lib/backend-auth";
+
+const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
+
+export async function GET(req: NextRequest) {
+ if (!API_BASE) {
+ return NextResponse.json(
+ { error: "POLYWEATHER_API_BASE_URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ try {
+ const auth = await buildBackendRequestHeaders(req);
+ const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
+ const days = req.nextUrl.searchParams.get("days");
+ if (days) {
+ url.searchParams.set("days", days);
+ }
+ const res = await fetch(url.toString(), {
+ headers: auth.headers,
+ cache: "no-store",
+ });
+ const raw = await res.text();
+ const response = new NextResponse(raw, {
+ status: res.status,
+ headers: {
+ "Content-Type": res.headers.get("content-type") || "application/json",
+ "Cache-Control": "no-store",
+ },
+ });
+ return applyAuthResponseCookies(response, auth.response);
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Failed to fetch analytics funnel", detail: String(error) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx
index eba41626..763b05d3 100644
--- a/frontend/components/account/AccountCenter.tsx
+++ b/frontend/components/account/AccountCenter.tsx
@@ -46,6 +46,7 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
+import { trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
const UnlockProOverlay = dynamic(
@@ -1439,6 +1440,24 @@ export function AccountCenter() {
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
+ useEffect(() => {
+ if (!showOverlay || isSubscribed) return;
+ trackAppEvent("paywall_viewed", {
+ entry: "account_center",
+ user_state: isAuthenticated ? "logged_in" : "guest",
+ expired: showExpiredReminder,
+ expiring_soon: showExpiringSoon,
+ subscription_plan_code: planCode || null,
+ });
+ }, [
+ isAuthenticated,
+ isSubscribed,
+ planCode,
+ showExpiredReminder,
+ showExpiringSoon,
+ showOverlay,
+ ]);
+
// Points Logic
const backendPointsRaw = Number(backend?.points);
const metadataPointsRaw = Number(
@@ -1670,6 +1689,12 @@ export function AccountCenter() {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
+ trackAppEvent("checkout_succeeded", {
+ entry: "account_center",
+ plan_code: selectedPlan?.plan_code || "pro_monthly",
+ intent_id: intentId,
+ tx_hash: txHash || null,
+ });
await loadSnapshot();
await loadPaymentSnapshot();
return;
@@ -1688,7 +1713,7 @@ export function AccountCenter() {
}
throw new Error("payment pending timeout");
},
- [loadPaymentSnapshot, loadSnapshot],
+ [loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
);
const signBindMessage = async (
@@ -2062,6 +2087,13 @@ export function AccountCenter() {
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data)
throw new Error("intent payload invalid");
+ trackAppEvent("checkout_started", {
+ entry: "account_center",
+ plan_code: selectedPlan?.plan_code || "pro_monthly",
+ intent_id: intentId,
+ use_points: billing.canRedeem && usePoints,
+ pay_amount_usd: billing.payAmount,
+ });
const intentReceiver = String(txPayload.to || "").toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(
@@ -2197,6 +2229,12 @@ export function AccountCenter() {
}
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
+ trackAppEvent("checkout_succeeded", {
+ entry: "account_center",
+ plan_code: selectedPlan?.plan_code || "pro_monthly",
+ intent_id: intentId,
+ tx_hash: txHashNorm,
+ });
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx
index 57dbaa03..9244f384 100644
--- a/frontend/components/dashboard/DetailPanel.tsx
+++ b/frontend/components/dashboard/DetailPanel.tsx
@@ -11,6 +11,7 @@ import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
+import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import {
getCityProfileStats,
@@ -183,6 +184,15 @@ export function DetailPanel() {
const handleFeatureAccess = (feature: "today" | "history") => {
blurActiveElement();
+ if (!isPro) {
+ trackAppEvent("paywall_feature_clicked", {
+ entry: "detail_panel",
+ feature,
+ city: store.selectedCity,
+ user_state: isAuthenticated ? "logged_in" : "guest",
+ });
+ }
+
if (isPro) {
if (feature === "today") {
void store.openTodayModal();
diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx
index d8b6c2b3..5810d290 100644
--- a/frontend/components/dashboard/ProFeaturePaywall.tsx
+++ b/frontend/components/dashboard/ProFeaturePaywall.tsx
@@ -1,10 +1,11 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
+import { trackAppEvent } from "@/lib/app-analytics";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
@@ -63,6 +64,14 @@ export function ProFeaturePaywall({
? "Sign In to Unlock Pro"
: "先登录再开通 Pro";
+ useEffect(() => {
+ trackAppEvent("paywall_viewed", {
+ entry: "feature_gate",
+ feature,
+ user_state: isAuthenticated ? "logged_in" : "guest",
+ });
+ }, [feature, isAuthenticated]);
+
return (
{
+ if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) {
+ return;
+ }
+ if (
+ markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session")
+ ) {
+ trackAppEvent("dashboard_active", {
+ subscription_active: proAccess.subscriptionActive,
+ subscription_plan_code: proAccess.subscriptionPlanCode,
+ });
+ }
+
+ const isTrialPlan = /trial/i.test(
+ String(proAccess.subscriptionPlanCode || ""),
+ );
+ if (
+ isTrialPlan &&
+ markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
+ ) {
+ trackAppEvent("signup_completed", {
+ source: "auth_me_trial",
+ subscription_plan_code: proAccess.subscriptionPlanCode,
+ });
+ }
+ }, [
+ proAccess.authenticated,
+ proAccess.loading,
+ proAccess.subscriptionActive,
+ proAccess.subscriptionPlanCode,
+ proAccess.userId,
+ ]);
+
useEffect(() => {
if (!cities.length) return;
diff --git a/frontend/lib/app-analytics.ts b/frontend/lib/app-analytics.ts
new file mode 100644
index 00000000..9b27436d
--- /dev/null
+++ b/frontend/lib/app-analytics.ts
@@ -0,0 +1,91 @@
+"use client";
+
+type TrackableAnalyticsEvent =
+ | "signup_completed"
+ | "dashboard_active"
+ | "paywall_feature_clicked"
+ | "paywall_viewed"
+ | "checkout_started"
+ | "checkout_succeeded";
+
+const CLIENT_ID_KEY = "polyweather:analytics:client-id";
+const SESSION_ID_KEY = "polyweather:analytics:session-id";
+
+function isClient() {
+ return typeof window !== "undefined";
+}
+
+function randomId() {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+function getStoredId(storage: Storage, key: string) {
+ let value = storage.getItem(key);
+ if (!value) {
+ value = randomId();
+ storage.setItem(key, value);
+ }
+ return value;
+}
+
+export function getAnalyticsClientId() {
+ if (!isClient()) return "";
+ try {
+ return getStoredId(window.localStorage, CLIENT_ID_KEY);
+ } catch {
+ return "";
+ }
+}
+
+export function getAnalyticsSessionId() {
+ if (!isClient()) return "";
+ try {
+ return getStoredId(window.sessionStorage, SESSION_ID_KEY);
+ } catch {
+ return "";
+ }
+}
+
+export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") {
+ if (!isClient()) return false;
+ const storage = scope === "local" ? window.localStorage : window.sessionStorage;
+ const normalizedKey = `polyweather:analytics:once:${key}`;
+ try {
+ if (storage.getItem(normalizedKey) === "1") {
+ return false;
+ }
+ storage.setItem(normalizedKey, "1");
+ return true;
+ } catch {
+ return true;
+ }
+}
+
+export function trackAppEvent(
+ eventType: TrackableAnalyticsEvent,
+ payload: Record = {},
+) {
+ if (!isClient()) return;
+ const body = {
+ event_type: eventType,
+ client_id: getAnalyticsClientId() || undefined,
+ session_id: getAnalyticsSessionId() || undefined,
+ payload: {
+ ...payload,
+ path: window.location.pathname,
+ href: window.location.href,
+ captured_at: new Date().toISOString(),
+ },
+ };
+ void fetch("/api/analytics/events", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ keepalive: true,
+ }).catch(() => {});
+}
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 56f8d552..c3ccc282 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -435,6 +435,7 @@ export interface HistoryState {
export interface ProAccessState {
loading: boolean;
authenticated: boolean;
+ userId: string | null;
subscriptionActive: boolean;
subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null;
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index 7f0dde9d..606802dd 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -50,6 +50,7 @@ function isPublicPage(pathname: string) {
function isPublicApi(pathname: string) {
return (
pathname === "/api/auth/me" ||
+ pathname === "/api/analytics/events" ||
pathname === "/api/cities" ||
pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
diff --git a/src/database/db_manager.py b/src/database/db_manager.py
index f69511aa..fa83663f 100644
--- a/src/database/db_manager.py
+++ b/src/database/db_manager.py
@@ -104,6 +104,23 @@ class DBManager:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)"
)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS app_analytics_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_type TEXT NOT NULL,
+ user_id TEXT,
+ client_id TEXT,
+ session_id TEXT,
+ payload_json TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_app_analytics_events_created_at ON app_analytics_events(created_at DESC)"
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_app_analytics_events_type_created_at ON app_analytics_events(event_type, created_at DESC)"
+ )
conn.execute("""
CREATE TABLE IF NOT EXISTS supabase_bindings (
supabase_user_id TEXT PRIMARY KEY,
@@ -197,6 +214,164 @@ class DBManager:
)
conn.commit()
+ def append_app_analytics_event(
+ self,
+ event_type: str,
+ payload: Dict[str, Any],
+ *,
+ user_id: Optional[str] = None,
+ client_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ ) -> None:
+ kind = str(event_type or "").strip().lower()
+ if not kind:
+ return
+ body = payload if isinstance(payload, dict) else {}
+ normalized_user_id = str(user_id or "").strip().lower() or None
+ normalized_client_id = str(client_id or "").strip() or None
+ normalized_session_id = str(session_id or "").strip() or None
+ with self._get_connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO app_analytics_events (
+ event_type,
+ user_id,
+ client_id,
+ session_id,
+ payload_json,
+ created_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ kind,
+ normalized_user_id,
+ normalized_client_id,
+ normalized_session_id,
+ json.dumps(body, ensure_ascii=False),
+ datetime.now().isoformat(),
+ ),
+ )
+ conn.commit()
+
+ def list_app_analytics_events(
+ self,
+ *,
+ limit: int = 200,
+ event_type: Optional[str] = None,
+ since_iso: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
+ safe_limit = max(1, min(int(limit or 200), 2000))
+ kind = str(event_type or "").strip().lower()
+ params: List[Any] = []
+ clauses: List[str] = []
+ if kind:
+ clauses.append("event_type = ?")
+ params.append(kind)
+ since_text = str(since_iso or "").strip()
+ if since_text:
+ clauses.append("created_at >= ?")
+ params.append(since_text)
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(safe_limit)
+ with self._get_connection() as conn:
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(
+ f"""
+ SELECT id, event_type, user_id, client_id, session_id, payload_json, created_at
+ FROM app_analytics_events
+ {where_sql}
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ tuple(params),
+ ).fetchall()
+ out: List[Dict[str, Any]] = []
+ for row in rows:
+ try:
+ payload = json.loads(str(row["payload_json"] or "{}"))
+ except Exception:
+ payload = {}
+ out.append(
+ {
+ "id": int(row["id"]),
+ "event_type": str(row["event_type"] or ""),
+ "user_id": str(row["user_id"] or "") or None,
+ "client_id": str(row["client_id"] or "") or None,
+ "session_id": str(row["session_id"] or "") or None,
+ "payload": payload if isinstance(payload, dict) else {},
+ "created_at": row["created_at"],
+ }
+ )
+ return out
+
+ def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
+ safe_days = max(1, min(int(days or 30), 365))
+ since_dt = datetime.now() - timedelta(days=safe_days)
+ rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat())
+ event_names = [
+ "signup_completed",
+ "dashboard_active",
+ "paywall_feature_clicked",
+ "paywall_viewed",
+ "checkout_started",
+ "checkout_succeeded",
+ ]
+ summary: Dict[str, Dict[str, Any]] = {
+ name: {
+ "total": 0,
+ "unique_users": 0,
+ "unique_actors": 0,
+ }
+ for name in event_names
+ }
+ actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
+ user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
+
+ for row in rows:
+ event_type = str(row.get("event_type") or "").strip().lower()
+ if event_type not in summary:
+ continue
+ summary[event_type]["total"] += 1
+ user_id = str(row.get("user_id") or "").strip().lower()
+ client_id = str(row.get("client_id") or "").strip()
+ session_id = str(row.get("session_id") or "").strip()
+ actor_key = ""
+ if user_id:
+ actor_key = f"user:{user_id}"
+ user_sets[event_type].add(user_id)
+ elif client_id:
+ actor_key = f"client:{client_id}"
+ elif session_id:
+ actor_key = f"session:{session_id}"
+ else:
+ actor_key = f"event:{row.get('id')}"
+ actor_sets[event_type].add(actor_key)
+
+ for name in event_names:
+ summary[name]["unique_users"] = len(user_sets[name])
+ summary[name]["unique_actors"] = len(actor_sets[name])
+
+ def _rate(numerator_key: str, denominator_key: str) -> Optional[float]:
+ denominator = int(summary[denominator_key]["unique_actors"] or 0)
+ numerator = int(summary[numerator_key]["unique_actors"] or 0)
+ if denominator <= 0:
+ return None
+ return round((numerator / denominator) * 100, 1)
+
+ return {
+ "window_days": safe_days,
+ "since": since_dt.isoformat(),
+ "events": summary,
+ "rates": {
+ "login_active_rate": _rate("dashboard_active", "signup_completed"),
+ "paywall_click_rate": _rate("paywall_feature_clicked", "dashboard_active"),
+ "paywall_view_rate": _rate("paywall_viewed", "paywall_feature_clicked"),
+ "checkout_start_rate": _rate("checkout_started", "paywall_viewed"),
+ "checkout_success_rate": _rate("checkout_succeeded", "checkout_started"),
+ },
+ }
+
def list_payment_audit_events(
self,
limit: int = 50,
diff --git a/web/core.py b/web/core.py
index 51074f3a..977a60c5 100644
--- a/web/core.py
+++ b/web/core.py
@@ -341,6 +341,13 @@ class ConfirmPaymentTxRequest(BaseModel):
tx_hash: Optional[str] = None
+class AnalyticsEventRequest(BaseModel):
+ event_type: str = Field(..., min_length=3, max_length=64)
+ client_id: Optional[str] = Field(default=None, max_length=128)
+ session_id: Optional[str] = Field(default=None, max_length=128)
+ payload: Dict[str, Any] = Field(default_factory=dict)
+
+
class GrantPointsRequest(BaseModel):
email: str = Field(..., min_length=3)
points: int = Field(..., gt=0, le=100000)
diff --git a/web/routes.py b/web/routes.py
index 18017b54..fddd236d 100644
--- a/web/routes.py
+++ b/web/routes.py
@@ -21,6 +21,7 @@ from web.analysis_service import (
_build_city_summary_payload,
)
from web.core import (
+ AnalyticsEventRequest,
CITIES,
CITY_REGISTRY,
CITY_RISK_PROFILES,
@@ -51,6 +52,15 @@ from web.core import (
router = APIRouter()
+TRACKABLE_ANALYTICS_EVENTS = {
+ "signup_completed",
+ "dashboard_active",
+ "paywall_feature_clicked",
+ "paywall_viewed",
+ "checkout_started",
+ "checkout_succeeded",
+}
+
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
raw = str(value or "").strip()
@@ -377,6 +387,32 @@ async def auth_me(request: Request):
}
+@router.post("/api/analytics/events")
+async def analytics_track(request: Request, body: AnalyticsEventRequest):
+ _bind_optional_supabase_identity(request)
+ event_type = str(body.event_type or "").strip().lower()
+ if event_type not in TRACKABLE_ANALYTICS_EVENTS:
+ raise HTTPException(status_code=400, detail="unsupported_event_type")
+
+ payload = body.payload if isinstance(body.payload, dict) else {}
+ normalized_payload = {
+ key: value
+ for key, value in payload.items()
+ if isinstance(key, str) and len(key) <= 64
+ }
+ from src.database.db_manager import DBManager
+
+ db = DBManager()
+ db.append_app_analytics_event(
+ event_type,
+ normalized_payload,
+ user_id=getattr(request.state, "auth_user_id", None),
+ client_id=body.client_id,
+ session_id=body.session_id,
+ )
+ return {"ok": True}
+
+
@router.get("/api/ops/users")
async def ops_search_users(request: Request, q: str = "", limit: int = 20):
_assert_entitlement(request)
@@ -509,6 +545,16 @@ async def ops_grant_points(request: Request, body: GrantPointsRequest):
return result
+@router.get("/api/ops/analytics/funnel")
+async def ops_analytics_funnel(request: Request, days: int = 30):
+ _assert_entitlement(request)
+ _require_ops_admin(request)
+ from src.database.db_manager import DBManager
+
+ db = DBManager()
+ return db.get_app_analytics_funnel_summary(days=days)
+
+
@router.get("/api/payments/config")
async def payment_config(request: Request):
_assert_entitlement(request)