Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions threadStax/README.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions threadStax/backend/README.md
Original file line number Diff line number Diff line change
@@ -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`.
73 changes: 73 additions & 0 deletions threadStax/backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -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))
11 changes: 11 additions & 0 deletions threadStax/backend/app/config.py
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 31 additions & 0 deletions threadStax/backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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)
125 changes: 125 additions & 0 deletions threadStax/backend/app/models.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 15 additions & 0 deletions threadStax/backend/app/rbac.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions threadStax/backend/app/redis_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading