DealDocumentScreening/src/contract_check/api/routes/me.py

221 lines
6.8 KiB
Python

"""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}