"""BFF — Backend for Frontend. Proxies to Tracker with user auth.""" import logging import time from fastapi import FastAPI, Request, Depends, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel from auth import create_token, get_current_user from config import AUTH_USER, AUTH_PASS, ENV from tracker_client import tracker_get, tracker_post, tracker_patch, tracker_delete, close_client from ws_proxy import router as ws_router logging.basicConfig( level=logging.DEBUG if ENV == "dev" else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger("bff") app = FastAPI(title="Team Board BFF", version="0.2.0") app.add_middleware( CORSMiddleware, allow_origins=["https://team.uix.su", "https://dev.team.uix.su", "http://localhost:3100"], allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def log_requests(request: Request, call_next): start = time.time() logger.info("[REQ] %s %s", request.method, request.url.path) try: response = await call_next(request) elapsed = (time.time() - start) * 1000 logger.info("[RES] %s %s → %d (%.0fms)", request.method, request.url.path, response.status_code, elapsed) return response except Exception as e: logger.error("[ERR] %s %s → %s", request.method, request.url.path, str(e)) return JSONResponse({"error": str(e)}, status_code=500) # --- Auth --- class LoginRequest(BaseModel): username: str password: str @app.post("/api/auth/login") async def login(data: LoginRequest): if data.username == AUTH_USER and data.password == AUTH_PASS: token = create_token(data.username) return {"token": token, "user": {"name": data.username, "slug": "admin"}} return JSONResponse({"error": "Invalid credentials"}, status_code=401) @app.get("/api/auth/me") async def me(user: dict = Depends(get_current_user)): return {"user": {"name": user["name"], "slug": user.get("sub", "admin")}} # --- Proxy: Projects --- @app.get("/api/v1/projects") async def list_projects(user: dict = Depends(get_current_user)): return await tracker_get("/api/v1/projects") @app.post("/api/v1/projects") async def create_project(request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post("/api/v1/projects", json=body) @app.get("/api/v1/projects/{slug}") async def get_project(slug: str, user: dict = Depends(get_current_user)): return await tracker_get(f"/api/v1/projects/{slug}") @app.patch("/api/v1/projects/{slug}") async def update_project(slug: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_patch(f"/api/v1/projects/{slug}", json=body) @app.delete("/api/v1/projects/{slug}") async def delete_project(slug: str, user: dict = Depends(get_current_user)): return await tracker_delete(f"/api/v1/projects/{slug}") # --- Proxy: Tasks --- @app.get("/api/v1/tasks") async def list_tasks( project_id: str = None, status: str = None, assignee: str = None, label: str = None, user: dict = Depends(get_current_user), ): params = {} if project_id: params["project_id"] = project_id if status: params["status"] = status if assignee: params["assignee"] = assignee if label: params["label"] = label return await tracker_get("/api/v1/tasks", params=params) @app.post("/api/v1/tasks") async def create_task(request: Request, project_slug: str = Query(...), user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post(f"/api/v1/tasks?project_slug={project_slug}", json=body) @app.get("/api/v1/tasks/{task_id}") async def get_task(task_id: str, user: dict = Depends(get_current_user)): return await tracker_get(f"/api/v1/tasks/{task_id}") @app.patch("/api/v1/tasks/{task_id}") async def update_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_patch(f"/api/v1/tasks/{task_id}", json=body) @app.delete("/api/v1/tasks/{task_id}") async def delete_task(task_id: str, user: dict = Depends(get_current_user)): return await tracker_delete(f"/api/v1/tasks/{task_id}") @app.post("/api/v1/tasks/{task_id}/take") async def take_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): return await tracker_post(f"/api/v1/tasks/{task_id}/take?slug={slug}") @app.post("/api/v1/tasks/{task_id}/reject") async def reject_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post(f"/api/v1/tasks/{task_id}/reject", json=body) @app.post("/api/v1/tasks/{task_id}/assign") async def assign_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post(f"/api/v1/tasks/{task_id}/assign", json=body) @app.post("/api/v1/tasks/{task_id}/watch") async def watch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): return await tracker_post(f"/api/v1/tasks/{task_id}/watch?slug={slug}") @app.delete("/api/v1/tasks/{task_id}/watch") async def unwatch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): return await tracker_delete(f"/api/v1/tasks/{task_id}/watch?slug={slug}") # --- Proxy: Steps --- @app.get("/api/v1/tasks/{task_id}/steps") async def list_steps(task_id: str, user: dict = Depends(get_current_user)): return await tracker_get(f"/api/v1/tasks/{task_id}/steps") @app.post("/api/v1/tasks/{task_id}/steps") async def create_step(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post(f"/api/v1/tasks/{task_id}/steps", json=body) @app.patch("/api/v1/tasks/{task_id}/steps/{step_id}") async def update_step(task_id: str, step_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_patch(f"/api/v1/tasks/{task_id}/steps/{step_id}", json=body) @app.delete("/api/v1/tasks/{task_id}/steps/{step_id}") async def delete_step(task_id: str, step_id: str, user: dict = Depends(get_current_user)): return await tracker_delete(f"/api/v1/tasks/{task_id}/steps/{step_id}") # --- Proxy: Messages (unified) --- @app.get("/api/v1/messages") async def list_messages( chat_id: str = None, task_id: str = None, limit: int = 50, offset: int = 0, user: dict = Depends(get_current_user), ): params = {"limit": limit, "offset": offset} if chat_id: params["chat_id"] = chat_id if task_id: params["task_id"] = task_id return await tracker_get("/api/v1/messages", params=params) @app.post("/api/v1/messages") async def create_message(request: Request, user: dict = Depends(get_current_user)): body = await request.json() # Inject author from auth body.setdefault("author_type", "human") body.setdefault("author_slug", user.get("sub", "admin")) return await tracker_post("/api/v1/messages", json=body) # --- Proxy: Members --- @app.get("/api/v1/members") async def list_members(user: dict = Depends(get_current_user)): return await tracker_get("/api/v1/members") @app.get("/api/v1/members/{slug}") async def get_member(slug: str, user: dict = Depends(get_current_user)): return await tracker_get(f"/api/v1/members/{slug}") @app.post("/api/v1/members") async def create_member(request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_post("/api/v1/members", json=body) @app.patch("/api/v1/members/{slug}") async def update_member(slug: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() return await tracker_patch(f"/api/v1/members/{slug}", json=body) @app.post("/api/v1/members/{slug}/regenerate-token") async def regenerate_token(slug: str, user: dict = Depends(get_current_user)): return await tracker_post(f"/api/v1/members/{slug}/regenerate-token") @app.post("/api/v1/members/{slug}/revoke-token") async def revoke_token(slug: str, user: dict = Depends(get_current_user)): return await tracker_post(f"/api/v1/members/{slug}/revoke-token") # --- Health --- @app.get("/health") async def health(): return {"status": "ok", "service": "bff", "version": "0.2.0"} # WebSocket proxy app.include_router(ws_router) @app.on_event("shutdown") async def shutdown(): await close_client()