287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""Ops users / feedback / points / analytics service functions."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
|
from web.core import GrantPointsRequest
|
|
import web.routes as legacy_routes
|
|
|
|
|
|
def _get_db():
|
|
from web.services.ops_api import DBManager
|
|
return DBManager()
|
|
|
|
# _require_ops is a lightweight auth guard duplicated in each ops submodule
|
|
# to avoid circular imports with the ops_api re-export hub.
|
|
def _require_ops(request: Request):
|
|
from web.services.ops_api import _require_ops as _real
|
|
return _real(request)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Internal helpers
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def _sf(value: Any) -> Optional[float]:
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _round_metric(value: Optional[float], digits: int = 1) -> Optional[float]:
|
|
return None if value is None else round(float(value), digits)
|
|
|
|
|
|
def _app_analytics_actor_key(row: Dict[str, Any]) -> str:
|
|
payload = row.get("payload")
|
|
payload = payload if isinstance(payload, dict) else {}
|
|
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
|
|
client_id = str(row.get("client_id") or "").strip()
|
|
session_id = str(row.get("session_id") or "").strip()
|
|
if user_id:
|
|
return f"user:{user_id}"
|
|
if client_id:
|
|
return f"client:{client_id}"
|
|
if session_id:
|
|
return f"session:{session_id}"
|
|
return f"event:{row.get('id')}"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Users
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
return {"users": db.search_users(q, limit=limit)}
|
|
|
|
|
|
def list_ops_audit_log(
|
|
request: Request,
|
|
*,
|
|
limit: int = 100,
|
|
action: str = "",
|
|
actor_email: str = "",
|
|
target_user_id: str = "",
|
|
) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
rows = db.list_ops_audit_events(
|
|
limit=limit,
|
|
action=action,
|
|
actor_email=actor_email,
|
|
target_user_id=target_user_id,
|
|
)
|
|
return {"events": rows, "total": len(rows)}
|
|
|
|
|
|
def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Points
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
|
|
admin = _require_ops(request) or {}
|
|
db = _get_db()
|
|
actor_email = str(admin.get("email") or "").strip().lower()
|
|
result = db.grant_points_by_supabase_email(
|
|
body.email,
|
|
body.points,
|
|
source="ops_manual_grant",
|
|
actor_email=actor_email,
|
|
reference_type="ops_action",
|
|
metadata={"action": "manual_points_grant"},
|
|
)
|
|
result["operator_email"] = actor_email
|
|
if not result.get("ok"):
|
|
reason = str(result.get("reason") or "grant_points_failed")
|
|
status_code = 404 if reason == "user_not_found" else 400
|
|
raise HTTPException(status_code=status_code, detail=result)
|
|
append_audit = getattr(db, "append_ops_audit_event", None)
|
|
if callable(append_audit):
|
|
audit = append_audit(
|
|
action="manual_points_grant",
|
|
actor_email=actor_email,
|
|
target_user_id=str(result.get("supabase_user_id") or ""),
|
|
target_email=str(result.get("supabase_email") or body.email),
|
|
target_type="user",
|
|
payload={
|
|
"points_added": int(result.get("points_added") or body.points),
|
|
"points_after": int(result.get("points_after") or 0),
|
|
},
|
|
)
|
|
result["audit_event_id"] = audit.get("id")
|
|
return result
|
|
|
|
|
|
def transfer_ops_points(
|
|
request: Request,
|
|
from_email: str = "",
|
|
to_email: str = "",
|
|
amount: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""Transfer points from one user to another."""
|
|
admin = _require_ops(request) or {}
|
|
from_email = str(from_email or "").strip()
|
|
to_email = str(to_email or "").strip()
|
|
amount = int(amount or 0)
|
|
if not from_email or not to_email:
|
|
raise HTTPException(
|
|
status_code=400, detail="from_email and to_email are required"
|
|
)
|
|
if amount <= 0:
|
|
raise HTTPException(status_code=400, detail="amount must be positive")
|
|
db = _get_db()
|
|
result = db.transfer_points_by_email(from_email, to_email, amount)
|
|
result["operator_email"] = admin.get("email")
|
|
if not result.get("ok"):
|
|
raise HTTPException(status_code=400, detail=result)
|
|
return result
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Analytics
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def get_ops_analytics_funnel(request: Request, days: int = 30) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
return db.get_app_analytics_funnel_summary(days=days)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Feedback
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def list_ops_feedback(
|
|
request: Request,
|
|
*,
|
|
limit: int = 100,
|
|
status: str = "",
|
|
) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
rows = db.list_user_feedback(limit=limit, status=status or None)
|
|
status_counts: Dict[str, int] = {}
|
|
recent_rows = db.list_user_feedback(limit=500)
|
|
for row in recent_rows:
|
|
key = str(row.get("status") or "unknown")
|
|
status_counts[key] = status_counts.get(key, 0) + 1
|
|
return {
|
|
"feedback": rows,
|
|
"total": len(rows),
|
|
"status_counts": status_counts,
|
|
}
|
|
|
|
|
|
def update_ops_feedback_status(
|
|
request: Request,
|
|
*,
|
|
feedback_id: int,
|
|
status: str,
|
|
) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
normalized = str(status or "").strip().lower()
|
|
allowed = {"open", "triaged", "investigating", "resolved", "closed"}
|
|
if normalized not in allowed:
|
|
raise HTTPException(status_code=400, detail="unsupported feedback status")
|
|
updated = _get_db().update_user_feedback_status(feedback_id, status=normalized)
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="feedback not found")
|
|
return {"ok": True, "feedback": updated}
|
|
|
|
|
|
def grant_ops_feedback_reward(
|
|
request: Request,
|
|
*,
|
|
feedback_id: int,
|
|
points: int,
|
|
reason: str = "",
|
|
) -> Dict[str, Any]:
|
|
admin = _require_ops(request) or {}
|
|
actor_email = str(admin.get("email") or "").strip().lower()
|
|
db = _get_db()
|
|
try:
|
|
result = db.grant_feedback_reward(
|
|
feedback_id,
|
|
points=points,
|
|
reason=reason,
|
|
actor_email=actor_email,
|
|
)
|
|
except TypeError as exc:
|
|
if "actor_email" not in str(exc):
|
|
raise
|
|
result = db.grant_feedback_reward(
|
|
feedback_id,
|
|
points=points,
|
|
reason=reason,
|
|
)
|
|
if not result.get("ok") and str(result.get("reason") or "") == "user_not_found":
|
|
feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
|
|
reward_status = str(feedback.get("reward_status") or "").strip().lower()
|
|
reward_points = int(feedback.get("reward_points") or 0)
|
|
supabase_user_id = str(feedback.get("user_id") or "").strip().lower()
|
|
if supabase_user_id and not (reward_status == "granted" and reward_points > 0):
|
|
try:
|
|
fallback = legacy_routes.SUPABASE_ENTITLEMENT.grant_points_to_user(
|
|
supabase_user_id,
|
|
points,
|
|
)
|
|
except Exception as exc:
|
|
fallback = {"ok": False, "reason": f"supabase_points_grant_failed:{exc}"}
|
|
if fallback.get("ok"):
|
|
updated_feedback = db.update_user_feedback_reward(
|
|
feedback_id,
|
|
points=points,
|
|
reason=reason,
|
|
status="granted",
|
|
)
|
|
result = {
|
|
**fallback,
|
|
"ok": True,
|
|
"feedback_id": int(feedback_id),
|
|
"supabase_user_id": supabase_user_id,
|
|
"feedback": updated_feedback,
|
|
}
|
|
result["operator_email"] = actor_email
|
|
if not result.get("ok"):
|
|
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
|
status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400
|
|
if reason_code == "already_rewarded":
|
|
status_code = 409
|
|
raise HTTPException(status_code=status_code, detail=result)
|
|
feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
|
|
append_audit = getattr(db, "append_ops_audit_event", None)
|
|
if callable(append_audit):
|
|
audit = append_audit(
|
|
action="feedback_reward_grant",
|
|
actor_email=actor_email,
|
|
target_user_id=str(
|
|
result.get("supabase_user_id")
|
|
or feedback.get("user_id")
|
|
or ""
|
|
),
|
|
target_email=str(result.get("supabase_email") or feedback.get("user_email") or ""),
|
|
target_type="feedback",
|
|
target_id=str(feedback_id),
|
|
payload={
|
|
"points_added": int(points or 0),
|
|
"reason": str(reason or ""),
|
|
"points_after": int(result.get("points_after") or 0),
|
|
},
|
|
)
|
|
result["audit_event_id"] = audit.get("id")
|
|
return result
|