diff --git a/threadStax/README.md b/threadStax/README.md new file mode 100644 index 000000000..a90b5b079 --- /dev/null +++ b/threadStax/README.md @@ -0,0 +1,23 @@ +# Threads Contest Engine + +A minimal full-stack MVP with FastAPI + Redis backend and Next.js frontend. + +## Quickstart + +```bash +cd backend +docker compose up -d +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +```bash +cd ../frontend +npm install +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 npm run dev +``` + +Frontend: http://localhost:3000 +Backend: http://localhost:8000 diff --git a/threadStax/backend/README.md b/threadStax/backend/README.md new file mode 100644 index 000000000..e8400be3d --- /dev/null +++ b/threadStax/backend/README.md @@ -0,0 +1,15 @@ +# Threads Contest Engine Backend + +## Setup + +```bash +docker compose up -d +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +## Notes +- Redis uses DB 7 by default (`redis://localhost:6379/7`). +- CORS is enabled for `http://localhost:3000`. diff --git a/threadStax/backend/app/auth.py b/threadStax/backend/app/auth.py new file mode 100644 index 000000000..45b9650ae --- /dev/null +++ b/threadStax/backend/app/auth.py @@ -0,0 +1,73 @@ +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .config import settings +from .models import Role, User +from .redis_client import get_redis +from . import redis_keys + +security = HTTPBearer(auto_error=False) + + +async def _find_user_by_username(username: str) -> Optional[User]: + redis = await get_redis() + async for key in redis.scan_iter(match=f"{settings.redis_key_prefix}u:*"): + data = await redis.hgetall(key) + if data.get("username") == username: + uid = key.split(":")[-1] + return User(uid=uid, **data) + return None + + +async def login_user(username: str, role: Role) -> User: + redis = await get_redis() + existing = await _find_user_by_username(username) + if existing: + return existing + uid = uuid4().hex + now = datetime.utcnow().isoformat() + user_data = { + "role": role, + "username": username, + "status": "active", + "created_at": now, + } + await redis.hset(redis_keys.user(uid), mapping=user_data) + await redis.sadd(redis_keys.role_set(role), uid) + return User(uid=uid, **user_data) + + +async def create_session(uid: str) -> str: + redis = await get_redis() + token = uuid4().hex + await redis.set(redis_keys.session(token), uid, ex=settings.session_ttl_seconds) + return token + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> User: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") + redis = await get_redis() + token = credentials.credentials + uid = await redis.get(redis_keys.session(token)) + if not uid: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + data = await redis.hgetall(redis_keys.user(uid)) + if not data: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return User(uid=uid, **data) + + +async def logout_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> None: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") + redis = await get_redis() + await redis.delete(redis_keys.session(credentials.credentials)) diff --git a/threadStax/backend/app/config.py b/threadStax/backend/app/config.py new file mode 100644 index 000000000..66d28f387 --- /dev/null +++ b/threadStax/backend/app/config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class Settings(BaseModel): + redis_url: str = "redis://localhost:6379/7" + session_ttl_seconds: int = 60 * 60 * 24 * 7 + redis_key_prefix: str = "tc:" + cors_origin: str = "http://localhost:3000" + + +settings = Settings() diff --git a/threadStax/backend/app/main.py b/threadStax/backend/app/main.py new file mode 100644 index 000000000..8189554e6 --- /dev/null +++ b/threadStax/backend/app/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .redis_client import get_redis +from .routes import admin, auth, judge, leaderboard, seasons, submissions, vote + +app = FastAPI(title="Threads Contest Engine") + +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.cors_origin], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +async def startup() -> None: + redis = await get_redis() + await redis.ping() + + +app.include_router(auth.router) +app.include_router(seasons.router) +app.include_router(submissions.router) +app.include_router(admin.router) +app.include_router(judge.router) +app.include_router(vote.router) +app.include_router(leaderboard.router) diff --git a/threadStax/backend/app/models.py b/threadStax/backend/app/models.py new file mode 100644 index 000000000..cb32f30e9 --- /dev/null +++ b/threadStax/backend/app/models.py @@ -0,0 +1,125 @@ +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +Role = Literal[ + "admin", + "judge", + "participant", + "voter", + "moderator", + "auditor", + "sponsor", +] + +SeasonStatus = Literal["draft", "live", "ended", "finalized"] +SubmissionStatus = Literal["pending", "approved", "rejected", "winner"] + + +class LoginRequest(BaseModel): + username: str = Field(min_length=2, max_length=64) + role: Role + + +class LoginResponse(BaseModel): + uid: str + token: str + role: Role + + +class User(BaseModel): + uid: str + username: str + role: Role + status: str + created_at: str + + +class Season(BaseModel): + sid: str + status: SeasonStatus + created_at: str + ends_at: Optional[str] = None + + +class CreateSeasonRequest(BaseModel): + sid: str = Field(min_length=2, max_length=64) + status: Optional[SeasonStatus] = None + + +class SeasonStatusRequest(BaseModel): + status: SeasonStatus + + +class SeedJudgesRequest(BaseModel): + usernames: list[str] + + +class SubmitRequest(BaseModel): + threads_url: HttpUrl + code_phrase: str = Field(min_length=2, max_length=120) + + +class SubmitResponse(BaseModel): + sub_id: str + + +class ModerationRejectRequest(BaseModel): + reason: Optional[str] = None + + +class ScoreRequest(BaseModel): + score: int = Field(ge=0, le=100) + notes: Optional[str] = None + + +class VoteResponse(BaseModel): + votes: int + + +class FinalizeRequest(BaseModel): + top_n: int = Field(default=10, ge=1, le=100) + + +class Submission(BaseModel): + sub_id: str + uid: str + threads_url: str + code_phrase: str + status: SubmissionStatus + created_at: str + review_notes: Optional[str] = None + agg_score: Optional[float] = None + + +class AssignedSubmission(BaseModel): + sub_id: str + uid: str + threads_url: str + code_phrase: str + status: SubmissionStatus + created_at: str + judge_score: Optional[int] = None + judge_notes: Optional[str] = None + + +class LeaderboardEntry(BaseModel): + sub_id: str + uid: str + username: str + threads_url: str + status: SubmissionStatus + score: float + + +class WinnerEntry(BaseModel): + sub_id: str + uid: str + avg: float + + +class ApiMessage(BaseModel): + message: str + timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat()) diff --git a/threadStax/backend/app/rbac.py b/threadStax/backend/app/rbac.py new file mode 100644 index 000000000..4cd51ee97 --- /dev/null +++ b/threadStax/backend/app/rbac.py @@ -0,0 +1,15 @@ +from collections.abc import Callable + +from fastapi import Depends, HTTPException, status + +from .auth import get_current_user +from .models import Role, User + + +def require_role(roles: list[Role]) -> Callable: + async def _dependency(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") + return user + + return _dependency diff --git a/threadStax/backend/app/redis_client.py b/threadStax/backend/app/redis_client.py new file mode 100644 index 000000000..a6cac96cb --- /dev/null +++ b/threadStax/backend/app/redis_client.py @@ -0,0 +1,13 @@ +import redis.asyncio as redis + +from .config import settings + + +redis_client: redis.Redis | None = None + + +async def get_redis() -> redis.Redis: + global redis_client + if redis_client is None: + redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True) + return redis_client diff --git a/threadStax/backend/app/redis_keys.py b/threadStax/backend/app/redis_keys.py new file mode 100644 index 000000000..24d03d1f0 --- /dev/null +++ b/threadStax/backend/app/redis_keys.py @@ -0,0 +1,88 @@ +from .config import settings + + +PREFIX = settings.redis_key_prefix + + +def user(uid: str) -> str: + return f"{PREFIX}u:{uid}" + + +def role_set(role: str) -> str: + return f"{PREFIX}role:{role}" + + +def session(token: str) -> str: + return f"{PREFIX}sess:{token}" + + +def season(sid: str) -> str: + return f"{PREFIX}season:{sid}" + + +def seasons() -> str: + return f"{PREFIX}seasons" + + +def submission(sid: str, sub_id: str) -> str: + return f"{PREFIX}s:{sid}:{sub_id}" + + +def submissions(sid: str) -> str: + return f"{PREFIX}subs:{sid}" + + +def user_submissions(uid: str, sid: str) -> str: + return f"{PREFIX}u:{uid}:subs:{sid}" + + +def moderation_queue(sid: str) -> str: + return f"{PREFIX}queue:mod:{sid}" + + +def flag(sid: str, sub_id: str) -> str: + return f"{PREFIX}flag:{sid}:{sub_id}" + + +def judge_assigned(sid: str, judge_uid: str) -> str: + return f"{PREFIX}judge:{sid}:{judge_uid}:assigned" + + +def score(sid: str, sub_id: str, judge_uid: str) -> str: + return f"{PREFIX}score:{sid}:{sub_id}:{judge_uid}" + + +def score_agg(sid: str, sub_id: str) -> str: + return f"{PREFIX}score:{sid}:{sub_id}:agg" + + +def leaderboard(sid: str) -> str: + return f"{PREFIX}lb:{sid}" + + +def vote_set(sid: str, sub_id: str) -> str: + return f"{PREFIX}vote:{sid}:{sub_id}" + + +def vote_count(sid: str, sub_id: str) -> str: + return f"{PREFIX}vote:{sid}:{sub_id}:count" + + +def vote_leaderboard(sid: str) -> str: + return f"{PREFIX}vlb:{sid}" + + +def payout(sid: str, uid: str) -> str: + return f"{PREFIX}payout:{sid}:{uid}" + + +def payout_queue(sid: str) -> str: + return f"{PREFIX}queue:payout:{sid}" + + +def rate_limit_submit(uid: str, sid: str) -> str: + return f"{PREFIX}rl:submit:{uid}:{sid}" + + +def rate_limit_vote(uid: str, sid: str) -> str: + return f"{PREFIX}rl:vote:{uid}:{sid}" diff --git a/threadStax/backend/app/routes/__init__.py b/threadStax/backend/app/routes/__init__.py new file mode 100644 index 000000000..3f81a022e --- /dev/null +++ b/threadStax/backend/app/routes/__init__.py @@ -0,0 +1,3 @@ +from . import admin, auth, judge, leaderboard, seasons, submissions, vote + +__all__ = ["admin", "auth", "judge", "leaderboard", "seasons", "submissions", "vote"] diff --git a/threadStax/backend/app/routes/admin.py b/threadStax/backend/app/routes/admin.py new file mode 100644 index 000000000..d5d5d513d --- /dev/null +++ b/threadStax/backend/app/routes/admin.py @@ -0,0 +1,132 @@ +import random +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..auth import login_user +from ..models import ( + ApiMessage, + CreateSeasonRequest, + FinalizeRequest, + ModerationRejectRequest, + SeedJudgesRequest, + Season, + SeasonStatusRequest, + Submission, + WinnerEntry, +) +from ..rbac import require_role +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.post("/season", response_model=Season) +async def create_season( + payload: CreateSeasonRequest, _: str = Depends(require_role(["admin"])) +) -> Season: + redis = await get_redis() + now = datetime.utcnow().isoformat() + status_value = payload.status or "draft" + key = redis_keys.season(payload.sid) + exists = await redis.exists(key) + if exists: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Season exists") + await redis.hset( + key, + mapping={"status": status_value, "created_at": now, "ends_at": ""}, + ) + await redis.zadd(redis_keys.seasons(), {payload.sid: datetime.utcnow().timestamp()}) + return Season(sid=payload.sid, status=status_value, created_at=now, ends_at=None) + + +@router.post("/season/{sid}/status", response_model=Season) +async def set_season_status( + sid: str, payload: SeasonStatusRequest, _: str = Depends(require_role(["admin"])) +) -> Season: + redis = await get_redis() + key = redis_keys.season(sid) + if not await redis.exists(key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found") + await redis.hset(key, mapping={"status": payload.status}) + data = await redis.hgetall(key) + return Season(sid=sid, **data) + + +@router.post("/seed/judges", response_model=ApiMessage) +async def seed_judges( + payload: SeedJudgesRequest, _: str = Depends(require_role(["admin"])) +) -> ApiMessage: + for username in payload.usernames: + await login_user(username.strip(), "judge") + return ApiMessage(message="Judges seeded") + + +@router.get("/season/{sid}/moderation/queue", response_model=list[Submission]) +async def moderation_queue( + sid: str, _: str = Depends(require_role(["admin", "moderator"])) +) -> list[Submission]: + redis = await get_redis() + sub_ids = await redis.lrange(redis_keys.moderation_queue(sid), 0, -1) + submissions: list[Submission] = [] + for sub_id in sub_ids: + data = await redis.hgetall(redis_keys.submission(sid, sub_id)) + if data: + submissions.append(Submission(sub_id=sub_id, **data)) + return submissions + + +@router.post("/season/{sid}/submissions/{sub_id}/approve") +async def approve_submission( + sid: str, sub_id: str, _: str = Depends(require_role(["admin", "moderator"])) +) -> dict: + redis = await get_redis() + key = redis_keys.submission(sid, sub_id) + if not await redis.exists(key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found") + await redis.hset(key, mapping={"status": "approved"}) + await redis.lrem(redis_keys.moderation_queue(sid), 0, sub_id) + judges = await redis.smembers(redis_keys.role_set("judge")) + judge_list = list(judges) + random.shuffle(judge_list) + assigned = judge_list[:3] + for judge_uid in assigned: + await redis.sadd(redis_keys.judge_assigned(sid, judge_uid), sub_id) + return {"assigned_judges": assigned} + + +@router.post("/season/{sid}/submissions/{sub_id}/reject", response_model=ApiMessage) +async def reject_submission( + sid: str, + sub_id: str, + payload: ModerationRejectRequest, + _: str = Depends(require_role(["admin", "moderator"])) +) -> ApiMessage: + redis = await get_redis() + key = redis_keys.submission(sid, sub_id) + if not await redis.exists(key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found") + await redis.hset(key, mapping={"status": "rejected", "review_notes": payload.reason or ""}) + await redis.lrem(redis_keys.moderation_queue(sid), 0, sub_id) + if payload.reason: + await redis.sadd(redis_keys.flag(sid, sub_id), payload.reason) + return ApiMessage(message="Submission rejected") + + +@router.post("/season/{sid}/finalize", response_model=list[WinnerEntry]) +async def finalize_season( + sid: str, payload: FinalizeRequest, _: str = Depends(require_role(["admin"])) +) -> list[WinnerEntry]: + redis = await get_redis() + lb_key = redis_keys.leaderboard(sid) + top_entries = await redis.zrevrange(lb_key, 0, payload.top_n - 1, withscores=True) + winners: list[WinnerEntry] = [] + for sub_id, avg in top_entries: + sub_key = redis_keys.submission(sid, sub_id) + data = await redis.hgetall(sub_key) + if not data: + continue + await redis.hset(sub_key, mapping={"status": "winner"}) + winners.append(WinnerEntry(sub_id=sub_id, uid=data.get("uid", ""), avg=float(avg))) + return winners diff --git a/threadStax/backend/app/routes/auth.py b/threadStax/backend/app/routes/auth.py new file mode 100644 index 000000000..61f4b4902 --- /dev/null +++ b/threadStax/backend/app/routes/auth.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends + +from ..auth import create_session, login_user, logout_user +from ..models import ApiMessage, LoginRequest, LoginResponse, User + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login(payload: LoginRequest) -> LoginResponse: + user = await login_user(payload.username, payload.role) + token = await create_session(user.uid) + return LoginResponse(uid=user.uid, token=token, role=user.role) + + +@router.post("/logout", response_model=ApiMessage) +async def logout(_: None = Depends(logout_user)) -> ApiMessage: + return ApiMessage(message="Logged out") + + +@router.get("/me", response_model=User) +async def me(user: User = Depends(get_current_user)) -> User: + return user diff --git a/threadStax/backend/app/routes/judge.py b/threadStax/backend/app/routes/judge.py new file mode 100644 index 000000000..bae7cf081 --- /dev/null +++ b/threadStax/backend/app/routes/judge.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..models import AssignedSubmission, ScoreRequest +from ..rbac import require_role +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/judge", tags=["judge"]) + + +@router.get("/season/{sid}/assigned", response_model=list[AssignedSubmission]) +async def assigned_submissions( + sid: str, user=Depends(require_role(["judge"])) +) -> list[AssignedSubmission]: + redis = await get_redis() + sub_ids = await redis.smembers(redis_keys.judge_assigned(sid, user.uid)) + results: list[AssignedSubmission] = [] + for sub_id in sub_ids: + sub_data = await redis.hgetall(redis_keys.submission(sid, sub_id)) + if not sub_data: + continue + score_data = await redis.hgetall(redis_keys.score(sid, sub_id, user.uid)) + judge_score = int(score_data["score"]) if score_data.get("score") else None + results.append( + AssignedSubmission( + sub_id=sub_id, + uid=sub_data.get("uid", ""), + threads_url=sub_data.get("threads_url", ""), + code_phrase=sub_data.get("code_phrase", ""), + status=sub_data.get("status", "pending"), + created_at=sub_data.get("created_at", ""), + judge_score=judge_score, + judge_notes=score_data.get("notes"), + ) + ) + return results + + +@router.post("/season/{sid}/score/{sub_id}") +async def score_submission( + sid: str, + sub_id: str, + payload: ScoreRequest, + user=Depends(require_role(["judge"])) +) -> dict: + redis = await get_redis() + sub_key = redis_keys.submission(sid, sub_id) + if not await redis.exists(sub_key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found") + score_key = redis_keys.score(sid, sub_id, user.uid) + agg_key = redis_keys.score_agg(sid, sub_id) + async with redis.pipeline(transaction=True) as pipe: + existing = await redis.hgetall(score_key) + current = await redis.hgetall(agg_key) + sum_value = float(current.get("sum", 0)) + count_value = int(current.get("count", 0)) + if existing and existing.get("score") is not None: + sum_value -= float(existing.get("score")) + else: + count_value += 1 + sum_value += payload.score + avg = sum_value / count_value if count_value else 0.0 + now = datetime.utcnow().isoformat() + pipe.hset(score_key, mapping={"score": payload.score, "notes": payload.notes or "", "updated_at": now}) + pipe.hset(agg_key, mapping={"sum": sum_value, "count": count_value, "avg": avg, "finalized": ""}) + pipe.zadd(redis_keys.leaderboard(sid), {sub_id: avg}) + await pipe.execute() + return {"avg": avg, "sum": sum_value, "count": count_value} diff --git a/threadStax/backend/app/routes/leaderboard.py b/threadStax/backend/app/routes/leaderboard.py new file mode 100644 index 000000000..48840b5f5 --- /dev/null +++ b/threadStax/backend/app/routes/leaderboard.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter + +from ..models import LeaderboardEntry +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/season", tags=["leaderboard"]) + + +async def _submission_to_entry(sid: str, sub_id: str, score: float) -> LeaderboardEntry | None: + redis = await get_redis() + sub_data = await redis.hgetall(redis_keys.submission(sid, sub_id)) + if not sub_data: + return None + user_data = await redis.hgetall(redis_keys.user(sub_data.get("uid", ""))) + return LeaderboardEntry( + sub_id=sub_id, + uid=sub_data.get("uid", ""), + username=user_data.get("username", ""), + threads_url=sub_data.get("threads_url", ""), + status=sub_data.get("status", "pending"), + score=score, + ) + + +@router.get("/{sid}/leaderboard/judges", response_model=list[LeaderboardEntry]) +async def leaderboard_judges(sid: str, limit: int = 50) -> list[LeaderboardEntry]: + redis = await get_redis() + items = await redis.zrevrange(redis_keys.leaderboard(sid), 0, limit - 1, withscores=True) + entries: list[LeaderboardEntry] = [] + for sub_id, score in items: + entry = await _submission_to_entry(sid, sub_id, float(score)) + if entry: + entries.append(entry) + return entries + + +@router.get("/{sid}/leaderboard/votes", response_model=list[LeaderboardEntry]) +async def leaderboard_votes(sid: str, limit: int = 50) -> list[LeaderboardEntry]: + redis = await get_redis() + items = await redis.zrevrange(redis_keys.vote_leaderboard(sid), 0, limit - 1, withscores=True) + entries: list[LeaderboardEntry] = [] + for sub_id, score in items: + entry = await _submission_to_entry(sid, sub_id, float(score)) + if entry: + entries.append(entry) + return entries diff --git a/threadStax/backend/app/routes/seasons.py b/threadStax/backend/app/routes/seasons.py new file mode 100644 index 000000000..9eded2a5d --- /dev/null +++ b/threadStax/backend/app/routes/seasons.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, HTTPException, status + +from ..models import Season +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/season", tags=["season"]) + + +@router.get("", response_model=list[Season]) +async def list_seasons() -> list[Season]: + redis = await get_redis() + entries = await redis.zrange(redis_keys.seasons(), 0, -1, withscores=False) + seasons: list[Season] = [] + for sid in entries: + data = await redis.hgetall(redis_keys.season(sid)) + if data: + seasons.append(Season(sid=sid, **data)) + return seasons + + +@router.get("/{sid}", response_model=Season) +async def get_season(sid: str) -> Season: + redis = await get_redis() + data = await redis.hgetall(redis_keys.season(sid)) + if not data: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found") + return Season(sid=sid, **data) diff --git a/threadStax/backend/app/routes/submissions.py b/threadStax/backend/app/routes/submissions.py new file mode 100644 index 000000000..b11809e9b --- /dev/null +++ b/threadStax/backend/app/routes/submissions.py @@ -0,0 +1,60 @@ +from datetime import datetime +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..models import SubmitRequest, SubmitResponse, Submission +from ..rbac import require_role +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/season", tags=["submissions"]) + + +@router.post("/{sid}/submit", response_model=SubmitResponse) +async def submit_entry( + sid: str, + payload: SubmitRequest, + user=Depends(require_role(["participant"])), +) -> SubmitResponse: + redis = await get_redis() + if not await redis.exists(redis_keys.season(sid)): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found") + rate_key = redis_keys.rate_limit_submit(user.uid, sid) + count = await redis.incr(rate_key) + if count == 1: + await redis.expire(rate_key, 60 * 60) + if count > 5: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Submit rate limit") + sub_id = uuid4().hex + now = datetime.utcnow().isoformat() + data = { + "uid": user.uid, + "threads_url": str(payload.threads_url), + "code_phrase": payload.code_phrase, + "status": "pending", + "created_at": now, + "review_notes": "", + } + await redis.hset(redis_keys.submission(sid, sub_id), mapping=data) + await redis.zadd(redis_keys.submissions(sid), {sub_id: datetime.utcnow().timestamp()}) + await redis.zadd(redis_keys.user_submissions(user.uid, sid), {sub_id: datetime.utcnow().timestamp()}) + await redis.rpush(redis_keys.moderation_queue(sid), sub_id) + return SubmitResponse(sub_id=sub_id) + + +@router.get("/{sid}/submissions/mine", response_model=list[Submission]) +async def my_submissions( + sid: str, user=Depends(require_role(["participant"])) +) -> list[Submission]: + redis = await get_redis() + sub_ids = await redis.zrevrange(redis_keys.user_submissions(user.uid, sid), 0, -1) + submissions: list[Submission] = [] + for sub_id in sub_ids: + data = await redis.hgetall(redis_keys.submission(sid, sub_id)) + if not data: + continue + agg = await redis.hgetall(redis_keys.score_agg(sid, sub_id)) + score = float(agg.get("avg")) if agg.get("avg") else None + submissions.append(Submission(sub_id=sub_id, agg_score=score, **data)) + return submissions diff --git a/threadStax/backend/app/routes/vote.py b/threadStax/backend/app/routes/vote.py new file mode 100644 index 000000000..fb96ce601 --- /dev/null +++ b/threadStax/backend/app/routes/vote.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from ..models import VoteResponse +from ..rbac import require_role +from ..redis_client import get_redis +from .. import redis_keys + +router = APIRouter(prefix="/season", tags=["vote"]) + + +@router.post("/{sid}/vote/{sub_id}", response_model=VoteResponse) +async def vote_submission( + sid: str, sub_id: str, user=Depends(require_role(["voter"])) +) -> VoteResponse: + redis = await get_redis() + if not await redis.exists(redis_keys.submission(sid, sub_id)): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found") + rate_key = redis_keys.rate_limit_vote(user.uid, sid) + count = await redis.incr(rate_key) + if count == 1: + await redis.expire(rate_key, 60 * 60) + if count > 50: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Vote rate limit") + vote_set_key = redis_keys.vote_set(sid, sub_id) + vote_count_key = redis_keys.vote_count(sid, sub_id) + vlb_key = redis_keys.vote_leaderboard(sid) + async with redis.pipeline(transaction=True) as pipe: + while True: + try: + await pipe.watch(vote_set_key) + if await pipe.sismember(vote_set_key, user.uid): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Already voted") + pipe.multi() + pipe.sadd(vote_set_key, user.uid) + pipe.incr(vote_count_key) + pipe.zincrby(vlb_key, 1, sub_id) + result = await pipe.execute() + votes = int(result[1]) + return VoteResponse(votes=votes) + except HTTPException: + raise + except Exception: + continue + finally: + await pipe.reset() diff --git a/threadStax/backend/docker-compose.yml b/threadStax/backend/docker-compose.yml new file mode 100644 index 000000000..b6e9e7593 --- /dev/null +++ b/threadStax/backend/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.9" +services: + redis: + image: redis:7 + ports: + - "6379:6379" + command: ["redis-server", "--save", "", "--appendonly", "no"] diff --git a/threadStax/backend/requirements.txt b/threadStax/backend/requirements.txt new file mode 100644 index 000000000..5a19d1584 --- /dev/null +++ b/threadStax/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +redis==5.0.8 +pydantic==2.8.2 diff --git a/threadStax/frontend/README.md b/threadStax/frontend/README.md new file mode 100644 index 000000000..77606537d --- /dev/null +++ b/threadStax/frontend/README.md @@ -0,0 +1,10 @@ +# Threads Contest Engine Frontend + +## Setup + +```bash +npm install +npm run dev +``` + +Set `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000` in your environment. diff --git a/threadStax/frontend/app/admin/page.tsx b/threadStax/frontend/app/admin/page.tsx new file mode 100644 index 000000000..ee8b44453 --- /dev/null +++ b/threadStax/frontend/app/admin/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useState } from "react"; +import RoleGate from "../../components/RoleGate"; +import SeasonPicker from "../../components/SeasonPicker"; +import AdminModerationQueue from "../../components/AdminModerationQueue"; +import Toast from "../../components/Toast"; +import { apiFetch } from "../../lib/api"; +import { Submission } from "../../lib/types"; + +export default function AdminPage() { + const [seasonId, setSeasonId] = useState(""); + const [newSeason, setNewSeason] = useState(""); + const [status, setStatus] = useState("draft"); + const [judgeUsernames, setJudgeUsernames] = useState(""); + const [queue, setQueue] = useState([]); + const [message, setMessage] = useState(null); + const [winners, setWinners] = useState([]); + const [topN, setTopN] = useState(10); + + const loadQueue = async (sid: string) => { + if (!sid) return; + const data = await apiFetch(`/admin/season/${sid}/moderation/queue`); + setQueue(data); + }; + + useEffect(() => { + if (seasonId) { + loadQueue(seasonId).catch(() => setQueue([])); + } + }, [seasonId]); + + const handleCreateSeason = async (event: React.FormEvent) => { + event.preventDefault(); + setMessage(null); + try { + await apiFetch("/admin/season", { + method: "POST", + body: JSON.stringify({ sid: newSeason, status }), + }); + setMessage("Season created."); + } catch { + setMessage("Season creation failed."); + } + }; + + const handleStatusUpdate = async () => { + if (!seasonId) return; + await apiFetch(`/admin/season/${seasonId}/status`, { + method: "POST", + body: JSON.stringify({ status }), + }); + setMessage("Season status updated."); + }; + + const handleSeedJudges = async () => { + const usernames = judgeUsernames + .split("\n") + .map((name) => name.trim()) + .filter(Boolean); + await apiFetch("/admin/seed/judges", { + method: "POST", + body: JSON.stringify({ usernames }), + }); + setJudgeUsernames(""); + setMessage("Judges seeded."); + }; + + const handleFinalize = async () => { + if (!seasonId) return; + const data = await apiFetch<{ uid: string; sub_id: string; avg: number }[]>( + `/admin/season/${seasonId}/finalize`, + { + method: "POST", + body: JSON.stringify({ top_n: topN }), + } + ); + setWinners(data.map((winner) => `${winner.uid} (${winner.sub_id})`)); + }; + + return ( + +
+
+

Admin Console

+
+ setNewSeason(event.target.value)} + placeholder="Season ID" + className="rounded-xl border border-white/10 bg-black/30 px-4 py-2" + /> + + +
+ {message && ( +
+ +
+ )} +
+ +
+

Manage Season

+
+ +
+
+ + +
+
+ +
+

Seed Judges

+