"""``/api/daily-challenge`` — Daily random challenge with 2× XP. Every 24 hours a random challenge is picked from the pool. Students who complete it get double XP (base + bonus via ``/claim``). """ import random from datetime import datetime, date, timedelta, timezone from typing import Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from app.core.auth import get_current_user from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_EDGE_URL from app.services.supabase_service import supabase_headers router = APIRouter() # Per-type tables we can pick a daily challenge from. _TABLES: list[tuple[str, str, str]] = [ ("encryption_challenges", "crypto", "red"), ("code_fixing_challenges", "code-fixing", "blue"), ("log_analysis_challenges", "log-analysis", "blue"), ("vulnerability_hunter_challenges", "vulnerability-hunter", "blue"), ("web_exploitation_challenges", "web-exploitation", "red"), ] class DailyChallengeResponse(BaseModel): challengeId: str challengeType: str teamRole: str title: str difficulty: str xpReward: int xpMultiplier: int = 2 timeRemaining: int # seconds until midnight completed: bool = False class ClaimRequest(BaseModel): challengeId: str xpAmount: int = 0 class ClaimResponse(BaseModel): bonus: int # --------------------------------------------------------------------------- # # GET /api/daily-challenge # # --------------------------------------------------------------------------- # @router.get("/api/daily-challenge") async def get_daily_challenge(user: dict = Depends(get_current_user)): today = date.today() dc = await _fetch_today_daily_challenge(today) if dc: challenge_info = await _fetch_challenge_info( dc["challenge_id"], dc["challenge_type"], dc["team_role"] ) if challenge_info: remaining = _seconds_until_midnight() completed = await _check_user_completed(user["user_id"], today) return DailyChallengeResponse( challengeId=dc["challenge_id"], challengeType=dc["challenge_type"], teamRole=dc["team_role"], title=challenge_info["title"], difficulty=challenge_info["difficulty"], xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])), timeRemaining=remaining, completed=completed, ) # No daily challenge for today yet — pick one. dc = await _pick_daily_challenge(today) if not dc: raise HTTPException(status_code=404, detail="لا توجد تحديات متاحة للتحدي اليومي") challenge_info = await _fetch_challenge_info( dc["challenge_id"], dc["challenge_type"], dc["team_role"] ) if not challenge_info: raise HTTPException(status_code=404, detail="التحدي غير متاح حالياً") remaining = _seconds_until_midnight() completed = await _check_user_completed(user["user_id"], today) return DailyChallengeResponse( challengeId=dc["challenge_id"], challengeType=dc["challenge_type"], teamRole=dc["team_role"], title=challenge_info["title"], difficulty=challenge_info["difficulty"], xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])), timeRemaining=remaining, completed=completed, ) # --------------------------------------------------------------------------- # # POST /api/daily-challenge/claim # # --------------------------------------------------------------------------- # @router.post("/api/daily-challenge/claim") async def claim_daily_challenge( req: ClaimRequest, request: Request, user: dict = Depends(get_current_user), ): today = date.today() dc = await _fetch_today_daily_challenge(today) if not dc or dc["challenge_id"] != req.challengeId: return ClaimResponse(bonus=0) bonus = req.xpAmount or int(dc.get("xp_reward", 150)) if bonus <= 0: return ClaimResponse(bonus=0) async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( f"{SUPABASE_EDGE_URL}/apex-xp", json={"action": "add_xp", "user_id": user["user_id"], "xp_amount": bonus}, ) if resp.status_code != 200: raise HTTPException(status_code=502, detail="فشل إضافة نقاط XP") # Mark as completed for this user (idempotent via UNIQUE) await _mark_user_completed(user["user_id"], today, req.challengeId) return ClaimResponse(bonus=bonus) # --------------------------------------------------------------------------- # # Internal helpers # # --------------------------------------------------------------------------- # async def _check_user_completed(user_id: str, today: date) -> bool: if not SUPABASE_URL or not SUPABASE_ANON_KEY: return False url = ( f"{SUPABASE_URL}/rest/v1/user_daily_completions" f"?user_id=eq.{user_id}&active_date=eq.{today.isoformat()}&limit=1" ) try: async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url, headers=supabase_headers()) if r.status_code == 200: return len(r.json()) > 0 except Exception as e: print(f"[daily_challenge] check_completed error: {e}") return False async def _mark_user_completed(user_id: str, today: date, challenge_id: str): if not SUPABASE_URL or not SUPABASE_ANON_KEY: return url = f"{SUPABASE_URL}/rest/v1/user_daily_completions" headers = supabase_headers(content_type=True) headers["Prefer"] = "return=minimal,resolution=ignore-duplicates" payload = { "user_id": user_id, "active_date": today.isoformat(), "challenge_id": challenge_id, } try: async with httpx.AsyncClient(timeout=10) as client: await client.post(url, json=payload, headers=headers) except Exception as e: print(f"[daily_challenge] mark_completed error: {e}") async def _fetch_today_daily_challenge(today: date) -> Optional[dict]: if not SUPABASE_URL or not SUPABASE_ANON_KEY: return None url = ( f"{SUPABASE_URL}/rest/v1/daily_challenges" f"?active_date=eq.{today.isoformat()}&limit=1" f"&order=created_at.desc" ) try: async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url, headers=supabase_headers()) if r.status_code == 200: rows = r.json() if rows: return rows[0] except Exception as e: print(f"[daily_challenge] fetch error: {e}") return None async def _pick_daily_challenge(today: date) -> Optional[dict]: if not SUPABASE_URL or not SUPABASE_ANON_KEY: return None candidates = [] for table, ctype, team in _TABLES: try: async with httpx.AsyncClient(timeout=10) as client: r = await client.get( f"{SUPABASE_URL}/rest/v1/{table}" f"?select=id,title,difficulty,xp_reward" f"&order=created_at.desc&limit=50", headers=supabase_headers(), ) if r.status_code == 200: rows = r.json() for row in rows: candidates.append((row, ctype, team)) except Exception as e: print(f"[daily_challenge] {table}: {e}") continue if not candidates: return None row, ctype, team = random.choice(candidates) xp_reward = int(row.get("xp_reward") or 150) payload = { "challenge_id": row["id"], "challenge_type": ctype, "team_role": team, "xp_reward": xp_reward, "active_date": today.isoformat(), } url = f"{SUPABASE_URL}/rest/v1/daily_challenges" headers = supabase_headers(content_type=True) headers["Prefer"] = "return=representation,resolution=ignore-duplicates" try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(url, json=payload, headers=headers) if r.status_code in (200, 201): rows = r.json() if rows: return rows[0] else: print(f"[daily_challenge] insert failed: {r.status_code} {r.text[:200]}") except Exception as e: print(f"[daily_challenge] insert exception: {e}") return payload # best-effort return async def _fetch_challenge_info( challenge_id: str, challenge_type: str, team_role: str ) -> Optional[dict]: table = { "crypto": "encryption_challenges", "code-fixing": "code_fixing_challenges", "log-analysis": "log_analysis_challenges", "vulnerability-hunter": "vulnerability_hunter_challenges", "web-exploitation": "web_exploitation_challenges", }.get(challenge_type) if not table or not SUPABASE_URL or not SUPABASE_ANON_KEY: return None url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{challenge_id}&limit=1" try: async with httpx.AsyncClient(timeout=10) as client: r = await client.get(url, headers=supabase_headers()) if r.status_code == 200: rows = r.json() if rows: return rows[0] except Exception as e: print(f"[daily_challenge] fetch_challenge_info: {e}") return None def _seconds_until_midnight() -> int: now = datetime.now(timezone.utc) midnight = datetime(now.year, now.month, now.day, tzinfo=timezone.utc).replace( hour=0, minute=0, second=0 ) + timedelta(days=1) return int((midnight - now).total_seconds())