"""User profile endpoint (credits balance + Telegram binding + passive profile).""" from __future__ import annotations import uuid from fastapi import APIRouter, status from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from src.contract_check.api.deps import ( AsyncSessionDep, CurrentUserDep, bind_telegram_to_user, get_credits, set_user_password, ) from src.contract_check.api.schemas import ( BindTelegramRequest, BindTelegramResponse, DocsOverview, ) from src.contract_check.api.schemas import MeCreditsResponse as MeResponse from src.contract_check.api.schemas import ( OverviewResponse, PlanOverview, SetPasswordRequest, UserProfile, UserProfilePatch, ) from src.contract_check.core.config import get_settings from src.contract_check.core.db.repositories import ( CreditsRepository, DocumentRepository, UserProfileRepository, UserRepository, ) from src.contract_check.core.db.repositories.profiles import UserProfileRow router = APIRouter(prefix="/api/v1/me", tags=["me"]) @router.get("", response_model=MeResponse) async def me( session: AsyncSessionDep, user: CurrentUserDep, ) -> MeResponse: credits = await get_credits(session, user.user_id) return MeResponse( telegram_id=user.telegram_id if user.telegram_id else None, credits_left=credits, ) @router.get("/documents") async def list_my_documents( session: AsyncSessionDep, user: CurrentUserDep, limit: int = 10, ) -> dict[str, object]: """Return the current user's recent documents for the bot /reports command.""" if limit < 1: limit = 1 if limit > 100: limit = 100 documents = await DocumentRepository(session).list_for_user(user.user_id, limit=limit, offset=0) return { "documents": [ { "document_id": str(doc.id), "filename": doc.filename, "status": doc.status, "stage": doc.stage, "created_at": doc.created_at.isoformat() if doc.created_at else None, } for doc in documents ] } @router.get("/profile", response_model=UserProfile) async def get_profile( session: AsyncSessionDep, user: CurrentUserDep, ) -> UserProfile: """Return the user's passive profile, materializing defaults on first read.""" row = await UserProfileRepository(session).get(user.user_id) await session.commit() return _row_to_profile(row) @router.patch("/profile", response_model=UserProfile) async def patch_profile( session: AsyncSessionDep, user: CurrentUserDep, body: UserProfilePatch, ) -> UserProfile: """Partially update the profile; only provided fields change (empty = no-op).""" repo = UserProfileRepository(session) # Lazy get first so PATCH on a never-read profile also works. await repo.get(user.user_id) row = await repo.update( user.user_id, language=body.language, timezone=body.timezone, notif_prefs=body.notif_prefs.model_dump() if body.notif_prefs else None, dashboard_prefs=body.dashboard_prefs.model_dump() if body.dashboard_prefs else None, ) await session.commit() return _row_to_profile(row) def _row_to_profile(row: UserProfileRow) -> UserProfile: return UserProfile( language=row.language, timezone=row.timezone, notif_prefs=dict(row.notif_prefs), # type: ignore[arg-type] dashboard_prefs=dict(row.dashboard_prefs), # type: ignore[arg-type] ) @router.get("/overview", response_model=OverviewResponse) async def get_overview( session: AsyncSessionDep, user: CurrentUserDep, ) -> OverviewResponse: """User dashboard: doc counts, credits, plan/quota state, billing hold.""" counts = await DocumentRepository(session).status_counts_for_user(user.user_id) credits_left = await CreditsRepository(session).get_balance(user.user_id) billing_hold = await UserRepository(session).get_billing_hold(user.user_id) plan: PlanOverview | None = None if get_settings().plans_enabled: plan = await _build_plan_overview(session, user.user_id) await session.commit() return OverviewResponse( docs=DocsOverview( total=counts["queued"] + counts["extracting"] + counts["prescreening"] + counts["ocr"] + counts["analyzing"] + counts["done"] + counts["failed"] + counts["manual_review"], done=counts["done"], failed=counts["failed"], in_progress=counts["queued"] + counts["extracting"] + counts["prescreening"] + counts["ocr"] + counts["analyzing"] + counts["manual_review"], ), credits_left=credits_left, plan=plan, billing_hold=billing_hold, ) async def _build_plan_overview( session: AsyncSession, user_id: uuid.UUID, ) -> PlanOverview | None: result = await session.execute( text( "SELECT s.plan_code, p.name, p.monthly_quota, s.current_period_end, " " s.current_period_start, s.id " "FROM subscriptions s " "JOIN plans p ON p.code = s.plan_code " "WHERE s.user_id = :u " " AND s.status = 'active' " " AND s.current_period_start <= now() " " AND s.current_period_end > now() " "LIMIT 1" ), {"u": user_id}, ) row = result.first() if row is None: return None plan_code, name, monthly_quota, period_end, period_start, sub_id = row used_result = await session.execute( text( "SELECT count(*) FROM quota_usage " "WHERE subscription_id = :sub AND created_at >= :start AND created_at < :end" ), {"sub": sub_id, "start": period_start, "end": period_end}, ) quota_used = int(used_result.scalar()) return PlanOverview( code=plan_code, name=name, monthly_quota=monthly_quota, quota_used=quota_used, period_end=period_end.isoformat(), ) @router.post("/telegram", response_model=BindTelegramResponse) async def bind_telegram( session: AsyncSessionDep, user: CurrentUserDep, body: BindTelegramRequest, ) -> BindTelegramResponse: """Link a Telegram account to the current web/email user.""" await bind_telegram_to_user(session, user.user_id, body.telegram_id) return BindTelegramResponse(ok=True, telegram_id=body.telegram_id) @router.post("/password", status_code=status.HTTP_200_OK) async def set_password( session: AsyncSessionDep, user: CurrentUserDep, body: SetPasswordRequest, ) -> dict[str, bool]: """Let a Telegram-only user set a web password to access the web UI.""" await set_user_password(session, user.user_id, body.password) return {"ok": True}