221 lines
6.8 KiB
Python
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}
|