diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 88b84d9d..57fd2e63 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -1258,6 +1258,80 @@ class DBManager: "points_after": after, } + def deduct_points_by_supabase_email( + self, + supabase_email: str, + amount: int, + ) -> Dict[str, Any]: + email = str(supabase_email or "").strip().lower() + points = int(amount or 0) + if not email: + return {"ok": False, "reason": "invalid_supabase_email"} + if points <= 0: + return {"ok": False, "reason": "invalid_amount"} + + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT telegram_id, username, points, supabase_email + FROM users + WHERE lower(trim(COALESCE(supabase_email, ''))) = ? + LIMIT 1 + """, + (email,), + ).fetchone() + if not row: + return {"ok": False, "reason": "user_not_found", "supabase_email": email} + + telegram_id = int(row["telegram_id"] or 0) + before = int(row["points"] or 0) + if before < points: + return { + "ok": False, + "reason": "insufficient_points", + "points_available": before, + "points_needed": points, + } + after = before - points + conn.execute( + "UPDATE users SET points = ? WHERE telegram_id = ?", + (after, telegram_id), + ) + conn.commit() + self._sync_points_to_supabase_user_metadata(telegram_id) + return { + "ok": True, + "telegram_id": telegram_id, + "username": str(row["username"] or ""), + "supabase_email": str(row["supabase_email"] or email), + "points_before": before, + "points_deducted": points, + "points_after": after, + } + + def transfer_points_by_email( + self, + from_email: str, + to_email: str, + amount: int, + ) -> Dict[str, Any]: + """Transfer points from one user to another within a single transaction.""" + r_from = self.deduct_points_by_supabase_email(from_email, amount) + if not r_from.get("ok"): + return {"ok": False, "reason": f"deduct_failed: {r_from.get('reason')}", "from": r_from} + r_to = self.grant_points_by_supabase_email(to_email, amount) + if not r_to.get("ok"): + # Rollback: grant back to source + self.grant_points_by_supabase_email(from_email, amount) + return {"ok": False, "reason": f"grant_failed: {r_to.get('reason')}", "to": r_to} + return { + "ok": True, + "from": r_from, + "to": r_to, + "amount": amount, + } + def upsert_user(self, telegram_id: int, username: str): with self._get_connection() as conn: conn.execute(""" diff --git a/web/routers/ops.py b/web/routers/ops.py index 506db86e..b5ade5a1 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -14,6 +14,7 @@ from web.services.ops_api import ( get_ops_weekly_leaderboard, get_ops_user_subscriptions, grant_ops_points, + transfer_ops_points, grant_ops_subscription, list_ops_memberships, list_ops_payment_incidents, @@ -78,6 +79,17 @@ async def ops_grant_points(request: Request, body: GrantPointsRequest): return grant_ops_points(request, body) +@router.post("/api/ops/users/transfer-points") +async def ops_transfer_points(request: Request): + import json as _json + body_bytes = await request.body() + body = _json.loads(body_bytes.decode("utf-8")) + from_email = str(body.get("from_email") or "").strip() + to_email = str(body.get("to_email") or "").strip() + amount = int(body.get("amount") or 0) + return transfer_ops_points(request, from_email=from_email, to_email=to_email, amount=amount) + + @router.get("/api/ops/analytics/funnel") async def ops_analytics_funnel(request: Request, days: int = 30): return get_ops_analytics_funnel(request, days=days) diff --git a/web/services/ops_api.py b/web/services/ops_api.py index f6eb413b..15fc5e39 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -239,6 +239,29 @@ def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, An 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 = DBManager() + 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 + + def get_ops_analytics_funnel(request: Request, days: int = 30) -> Dict[str, Any]: _require_ops(request) db = DBManager()