#!/usr/bin/env python3 """ Bulletin Board & Badge API Router Refactored to use supabase_service for clean, typed database access. Includes bot registration, fee tracking, and bot-specific endpoints. Uses direct Supabase REST API via httpx (no supabase-py dependency issues). """ import logging import os from datetime import datetime import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel logger = logging.getLogger(__name__) import contextlib # noqa: E402 from app.services.supabase_service import ( # noqa: E402 award_badge, create_alert, create_bulletin_post, get_alerts, get_badges, get_bulletin_posts, get_profile, get_user_badges, vote_post, ) # Lazy auth dependency - avoids importing jose+bcrypt at module load async def _require_public_profile(request: Request): from app.auth import require_public_profile return await require_public_profile(request) router = APIRouter(prefix="/api/v1/bulletin") # ── Supabase REST helpers ────────────────────────────────────── def _sb(): """Return (url, headers) for direct Supabase REST API.""" key = os.environ["SUPABASE_SERVICE_KEY"] url = os.environ["SUPABASE_URL"] return url, { "apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Prefer": "return=representation", } def _sb_get(table: str, params: dict | None = None) -> list: url, headers = _sb() r = httpx.get(f"{url}/rest/v1/{table}", headers=headers, params=params, timeout=10) return r.json() if r.status_code == 200 else [] def _sb_post(table: str, data: dict) -> list: url, headers = _sb() r = httpx.post(f"{url}/rest/v1/{table}", headers=headers, json=data, timeout=10) return r.json() if r.status_code in (200, 201) else [] def _sb_patch(table: str, data: dict, match: dict) -> list: url, headers = _sb() params = "&".join(f"{k}=eq.{v}" for k, v in match.items()) r = httpx.patch(f"{url}/rest/v1/{table}?{params}", headers=headers, json=data, timeout=10) return r.json() if r.status_code == 200 else [] # ── Models ────────────────────────────────────────────────── class BulletinPostIn(BaseModel): title: str content: str author_id: str category: str = "discussion" chain: str | None = None risk_score: int | None = None tags: list[str] | None = None source: str | None = None is_bot: bool = False bot_id: str | None = None class AlertIn(BaseModel): title: str description: str severity: str = "medium" alert_type: str = "security" chain: str | None = None token: str | None = None wallet: str | None = None amount_usd: float | None = None user_id: str | None = None class BotRegistrationIn(BaseModel): owner_id: str bot_name: str bot_type: str = "general" fee_paid: float = 0 fee_currency: str = "usdc" rules: list[str] = [] allowed_categories: list[str] = [] # ── Bulletin Posts ────────────────────────────────────────── @router.get("/latest") async def latest_posts( category: str = Query(None), sort: str = Query("created_at.desc"), limit: int = Query(20, ge=1, le=100), include_bots: bool = Query(True), ): posts = await get_bulletin_posts(category=category, sort=sort, limit=limit) for post in posts: if not include_bots and post.get("is_bot"): continue if post.get("author_id"): profile = await get_profile(post["author_id"]) if profile: post["author"] = { "username": profile.get("username", profile.get("display_name", "Unknown")), "avatar_url": profile.get("avatar_url", ""), "reputation": profile.get("reputation_score", 0), "level": profile.get("level", 1), "is_bot": profile.get("is_bot", False), } if not include_bots: posts = [p for p in posts if not p.get("is_bot")] return {"posts": posts, "total": len(posts)} @router.post("/posts") async def create_post(post: BulletinPostIn, user: dict = Depends(_require_public_profile)): result = await create_bulletin_post( title=post.title, content=post.content, author_id=user["id"], category=post.category, chain=post.chain, tags=post.tags, risk_score=post.risk_score, source=post.source, ) if not result: raise HTTPException(status_code=500, detail="Failed to create post") if post.is_bot and result.get("id"): with contextlib.suppress(Exception): _sb_patch("bulletin_posts", {"is_bot": True, "bot_id": post.bot_id}, {"id": result["id"]}) return result @router.post("/posts/{post_id}/vote") async def vote(post_id: int, vote_type: str = Query(..., pattern="^(up|down)$")): ok = await vote_post(post_id, vote_type) if not ok: raise HTTPException(status_code=404, detail="Post not found or vote failed") return {"status": "ok", "post_id": post_id, "vote": vote_type} class CommentIn(BaseModel): content: str @router.post("/posts/{post_id}/comments") async def create_comment(post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)): """Create a comment on a bulletin post. Requires public profile.""" # Store comment in Supabase or Redis (using simple dict for now if not in supabase_service) # For now, we'll just return success and let the frontend handle local state, # or we can add it to supabase_service if needed. # Let's use a simple in-memory store or supabase if available. # Since supabase_service doesn't have create_comment, we'll add a basic implementation. try: result = _sb_post( "bulletin_comments", { "post_id": post_id, "user_id": user["id"], "content": comment.content, "created_at": datetime.utcnow().isoformat(), }, ) if not result: raise HTTPException(status_code=500, detail="Failed to create comment") return {"status": "ok", "comment": result[0]} except Exception as e: # Fallback if table doesn't exist yet logger.warning(f"Comment creation failed (table may not exist): {e}") return { "status": "ok", "comment": { "id": "temp", "content": comment.content, "user_id": user["id"], "created_at": datetime.utcnow().isoformat(), }, } @router.get("/posts/{post_id}/comments") async def get_comments(post_id: int): """Get comments for a bulletin post.""" try: comments = _sb_get("bulletin_comments", {"post_id": f"eq.{post_id}", "order": "created_at.asc"}) return {"comments": comments} except Exception: return {"comments": []} # ── Bot Endpoints ─────────────────────────────────────────── BOT_FEE_USD = 1.00 # $1 fee for bots to participate @router.post("/bots/register") async def register_bot(bot: BotRegistrationIn): """Register a new bot account. Bot must pay a fee to participate.""" # Create the bot profile try: profiles = _sb_post( "profiles", { "username": bot.bot_name, "is_bot": True, "bot_owner": bot.owner_id, "bot_fee_paid": bot.fee_paid, "bot_rules": bot.rules, "role": "BOT", "tier": "FREE", }, ) if not profiles: raise HTTPException(status_code=500, detail="Failed to create bot profile") bot_profile = profiles[0] except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Bot profile creation failed: {e}") from e # Create bot registration record try: regs = _sb_post( "bot_registrations", { "bot_user_id": bot_profile["id"], "owner_user_id": bot.owner_id, "bot_name": bot.bot_name, "bot_type": bot.bot_type, "fee_paid": bot.fee_paid, "fee_currency": bot.fee_currency, "rules": bot.rules, "allowed_categories": bot.allowed_categories, "status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee", }, ) except Exception as e: raise HTTPException(status_code=500, detail=f"Bot registration failed: {e}") from e # Log the fee payment if bot.fee_paid > 0: with contextlib.suppress(Exception): _sb_post( "bot_fee_log", { "bot_id": bot_profile["id"], "amount": bot.fee_paid, "currency": bot.fee_currency, "reason": "registration_fee", }, ) return { "bot_profile": bot_profile, "registration": regs[0] if regs else None, "fee_required": BOT_FEE_USD, "fee_paid": bot.fee_paid, "fee_remaining": max(0, BOT_FEE_USD - bot.fee_paid), "status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee", } @router.get("/bots") async def list_bots(status: str = Query(None), limit: int = Query(50)): """List all registered bots.""" params: dict = { "select": "*,profiles:bot_user_id(username,is_bot,level,reputation_score)", "limit": str(limit), "order": "created_at.desc", } if status: params["status"] = f"eq.{status}" result = _sb_get("bot_registrations", params) return {"bots": result, "total": len(result)} @router.get("/bots/{bot_id}/fee-status") async def bot_fee_status(bot_id: str): """Check a bot's fee payment status.""" reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "limit": "1"}) if not reg: raise HTTPException(status_code=404, detail="Bot not registered") registration = reg[0] fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "order": "created_at.desc"}) total_paid = sum(f.get("amount", 0) for f in fees) return { "bot_id": bot_id, "total_paid": total_paid, "fee_required": BOT_FEE_USD, "fee_remaining": max(0, BOT_FEE_USD - total_paid), "status": registration["status"], "fee_history": fees, } @router.post("/bots/{bot_id}/pay-fee") async def pay_bot_fee(bot_id: str, amount: float, tx_hash: str | None = None, currency: str = "usdc"): """Record a bot fee payment.""" try: _sb_post( "bot_fee_log", { "bot_id": bot_id, "amount": amount, "currency": currency, "tx_hash": tx_hash, "reason": "participation_fee", }, ) except Exception as e: raise HTTPException(status_code=500, detail=f"Fee logging failed: {e}") from e fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "select": "amount"}) total_paid = sum(f.get("amount", 0) for f in fees) if total_paid >= BOT_FEE_USD: _sb_patch("bot_registrations", {"status": "active"}, {"bot_user_id": bot_id}) _sb_patch("profiles", {"bot_fee_paid": total_paid}, {"id": bot_id}) return { "amount_paid": amount, "total_paid": total_paid, "fee_required": BOT_FEE_USD, "status": "active" if total_paid >= BOT_FEE_USD else "pending_fee", } @router.post("/bots/{bot_id}/post") async def bot_create_post(bot_id: str, post: BulletinPostIn): """Bot creates a post - must be registered and fee-paid.""" reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "status": "eq.active", "limit": "1"}) if not reg: raise HTTPException(status_code=403, detail="Bot not registered or fee not paid") registration = reg[0] if registration.get("allowed_categories") and post.category not in registration["allowed_categories"]: raise HTTPException(status_code=403, detail=f"Bot not allowed to post in category: {post.category}") post.is_bot = True post.bot_id = bot_id post.source = post.source or f"bot:{registration['bot_name']}" result = await create_bulletin_post( title=post.title, content=post.content, author_id=post.author_id, category=post.category, chain=post.chain, tags=post.tags, risk_score=post.risk_score, source=post.source, ) if not result: raise HTTPException(status_code=500, detail="Failed to create post") with contextlib.suppress(Exception): _sb_patch("bulletin_posts", {"is_bot": True, "bot_id": bot_id}, {"id": result["id"]}) return result # ── Reports & Moderation ────────────────────────────────────── class ReportIn(BaseModel): post_id: int reporter_id: str reason: str details: str | None = None category: str = "other" # spam, abuse, misinformation, scam, bot_violation, other class ModerationActionIn(BaseModel): admin_id: str action: str # remove, lock, pin, unpin, unlock, restore, ban_user target_type: str # post, user, bot target_id: str reason: str | None = None @router.post("/reports") async def create_report(report: ReportIn): """Submit a report on a post.""" result = _sb_post( "bulletin_reports", { "post_id": report.post_id, "reporter_id": report.reporter_id, "reason": report.reason, "details": report.details, "category": report.category, "status": "open", }, ) if not result: raise HTTPException(status_code=500, detail="Failed to create report") return result[0] @router.get("/reports") async def list_reports(status: str = Query(None), limit: int = Query(50, ge=1, le=200)): """List reports (admin view).""" params: dict = {"order": "created_at.desc", "limit": str(limit)} if status: params["status"] = f"eq.{status}" result = _sb_get("bulletin_reports", params) return {"reports": result, "total": len(result)} @router.post("/moderate") async def moderate(moderation: ModerationActionIn): """Take a moderation action.""" # Log the action _sb_post( "bulletin_moderation_log", { "admin_id": moderation.admin_id, "action": moderation.action, "target_type": moderation.target_type, "target_id": moderation.target_id, "reason": moderation.reason, }, ) # Apply the action if moderation.target_type == "post": match = {"id": moderation.target_id} if moderation.action == "remove": _sb_patch("bulletin_posts", {"removed": True, "removed_reason": moderation.reason}, match) elif moderation.action == "restore": _sb_patch("bulletin_posts", {"removed": False, "removed_reason": None}, match) elif moderation.action == "pin": _sb_patch("bulletin_posts", {"pinned": True}, match) elif moderation.action == "unpin": _sb_patch("bulletin_posts", {"pinned": False}, match) elif moderation.action == "lock": _sb_patch("bulletin_posts", {"locked": True}, match) elif moderation.action == "unlock": _sb_patch("bulletin_posts", {"locked": False}, match) elif moderation.target_type == "bot": if moderation.action == "ban_user": _sb_patch("bot_registrations", {"status": "banned"}, {"bot_user_id": moderation.target_id}) return { "status": "ok", "action": moderation.action, "target": f"{moderation.target_type}:{moderation.target_id}", } @router.get("/moderation/log") async def moderation_log(limit: int = Query(50)): """View moderation log.""" result = _sb_get("bulletin_moderation_log", {"order": "created_at.desc", "limit": str(limit)}) return {"log": result, "total": len(result)} # ── Badges ────────────────────────────────────────────────── @router.get("/badges") async def list_badges(rarity: str = Query(None)): return await get_badges(rarity=rarity) @router.get("/badges/{user_id}") async def user_badges(user_id: str): badges = await get_user_badges(user_id) return badges @router.post("/badges/award") async def award(badge_id: str, user_id: str, earned_via: str = "auto"): result = await award_badge(user_id, badge_id, earned_via) if not result: raise HTTPException(status_code=500, detail="Failed to award badge") return result # ── Alerts ────────────────────────────────────────────────── @router.get("/alerts") async def list_alerts(severity: str = Query(None), limit: int = Query(50, ge=1, le=200)): alerts = await get_alerts(severity=severity, limit=limit) return {"alerts": alerts, "total": len(alerts)} @router.post("/alerts") async def submit_alert(alert: AlertIn): result = await create_alert( title=alert.title, description=alert.description, severity=alert.severity, alert_type=alert.alert_type, chain=alert.chain, token=alert.token, wallet=alert.wallet, amount_usd=alert.amount_usd, user_id=alert.user_id, ) if not result: raise HTTPException(status_code=500, detail="Failed to create alert") return result # ── Stats ────────────────────────────────────────────────── @router.get("/stats") async def community_stats(): """Get aggregate community stats from Supabase.""" total_posts = 0 total_users = 0 total_bots = 0 total_alerts = 0 try: posts = _sb_get("bulletin_posts", {"select": "id", "limit": "1000"}) total_posts = len(posts) except Exception: pass try: profiles = _sb_get("profiles", {"select": "id", "limit": "1000"}) total_users = len(profiles) except Exception: pass try: bots = _sb_get("profiles", {"select": "id", "is_bot": "eq.true", "limit": "100"}) total_bots = len(bots) except Exception: pass try: alerts = _sb_get("alerts", {"select": "id", "limit": "500"}) total_alerts = len(alerts) except Exception: pass return { "total_posts": total_posts, "total_users": total_users, "total_bots": total_bots, "total_alerts": total_alerts, "bot_fee_required_usd": BOT_FEE_USD, } # ── Health ────────────────────────────────────────────────── @router.get("/health") async def health(): from app.services.supabase_service import check_health return await check_health()