"""First-screen aggregate endpoints for the mobile workspace. These collapse the 5-6 parallel calls the original mobile home page made (asset transfers + provisioning tasks + my assets + inbox + inbox summary) into one round-trip. Feishu in-app webview latency on iOS makes this a perceived-perf lever, not a micro-optimisation. The shape is role-aware: an employee gets ``team_*`` / ``admin_*`` counts of 0, a manager gets non-zero ``team_*`` if they have direct reports, a ``global_admin`` gets non-zero ``admin_*``. The client uses ``available_roles`` to decide which cards to mount, not whether a specific count is non-zero (so a global_admin with zero pending approvals still sees the empty admin card, signalling "you have an admin view, nothing's pending right now"). """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.approval.models import ApprovalRequest from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.person_scope import ( SubordinateScope, employment_status_clause, subordinate_person_ids, ) from app.core.rbac import ( ASSET_ADMIN_ROLE, GLOBAL_ADMIN_ROLE, has_role, ) from app.core.response import success_response from app.domains.asset.models import AssetItem from app.domains.offboarding.models import ( PROC_BLOCKED, PROC_CANCELLED, PROC_COMPLETED, RESO_PENDING, XFER_PENDING, AssetTransferRequest, OffboardingBlocker, OffboardingProcess, ) from app.domains.provisioning.models import ( STATUS_IN_PROGRESS, STATUS_NOTIFIED, STATUS_PENDING, ProvisioningTask, ) from app.identity.person_models import IdentityPerson from app.mobile.action_inbox import build_action_inbox from app.mobile.apps_catalog import pinned_apps_for from app.mobile.perspective import resolve_perspective_role_names from app.mobile.schemas import ( MobileAppEntry, MobileHomeCounts, MobileHomeInboxItem, MobileHomeResponse, MobileHomeUser, ) from app.notifications.inbox_service import InboxService router = APIRouter(prefix="/aggregate", tags=["mobile"]) # Number of inbox rows to embed in the home payload. Three is the # fold-line for the H5 workspace home — a full inbox view lives on # the bell-icon tab so we don't pay for a longer payload here. _HOME_INBOX_LIMIT = 3 @router.get("/home", summary="Mobile workspace home payload") async def mobile_home( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Bundle the workspace home view into a single round-trip. Composition: - User identity snapshot (no extra ``/auth/me`` call needed) - Switchable perspective set (``available_roles``) + the derived ``is_manager`` fact (having ≥1 live direct report) — manager is a fact, not a switchable role (mirrors desktop 2026-06-15) - Counts for the role cards (employee / manager / admin) — every role's count is computed unconditionally so the client doesn't flicker between renders when a permission changes; admin counts the user has no business seeing are simply 0 - Top-N most recent inbox rows for the home preview strip """ person_id: UUID = current_user.id role_names = list(current_user.role_names or []) # Manager status is derived from org structure, not from a stored # role — match the dashboard router so the two surfaces agree. It is # surfaced as the ``is_manager`` fact (below), NOT injected into # ``available_roles``: aligned with desktop (2026-06-15) which dropped # ``manager`` from the view-role switcher. Being a manager gates the # team card / team tab, but it is not a perspective you flip into. has_subordinates = await _has_subordinates(db, person_id) available_roles = list(role_names) # Everyone can view the platform as a plain employee — every # authenticated person holds self-scope by definition (mirrors the # desktop MainLayout switcher, which always offers ``employee``). # Without this an admin / auditor / finance user could never drop to # the employee perspective on mobile, because ``employee`` is an # implicit baseline role, not a stored one in ``role_names``. if "employee" not in available_roles: available_roles.append("employee") is_global_admin = has_role(role_names, GLOBAL_ADMIN_ROLE) is_asset_admin = has_role(role_names, ASSET_ADMIN_ROLE, GLOBAL_ADMIN_ROLE) # Employee counts (always populated) pending_xfers = await _scalar_count( db, select(func.count()) .select_from(AssetTransferRequest) .where( and_( AssetTransferRequest.recipient_id == person_id, AssetTransferRequest.status == XFER_PENDING, ) ), ) # Count tasks the caller must ACT ON (assignee = the software owner / # IT admin who fulfils), not tasks merely about them (user_id). Keeps # the home "待办" badge consistent with the action inbox list, which # also surfaces provisioning by assignee_id (FINAL-B logic fix). pending_tasks = await _scalar_count( db, select(func.count()) .select_from(ProvisioningTask) .where( and_( ProvisioningTask.assignee_id == person_id, ProvisioningTask.status.in_( [STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_NOTIFIED], ), ) ), ) assigned_assets = await _scalar_count( db, select(func.count()) .select_from(AssetItem) .where( and_( AssetItem.assigned_to == person_id, AssetItem.status == "assigned", ) ), ) inbox_svc = InboxService(db) inbox_summary = await inbox_svc.summary_for_recipient(recipient_id=person_id) inbox_page = await inbox_svc.list_for_recipient( recipient_id=person_id, page=1, page_size=_HOME_INBOX_LIMIT, ) # Manager counts team_subordinates = 0 team_leavers_pending = 0 if has_subordinates: sub_ids = await _subordinate_ids(db, person_id) team_subordinates = len(sub_ids) team_leavers_pending = await _scalar_count( db, select(func.count(func.distinct(OffboardingProcess.id))) .select_from(OffboardingProcess) .where( and_( OffboardingProcess.person_id.in_(sub_ids), OffboardingProcess.status == PROC_BLOCKED, ) ), ) # Admin counts (gated — non-admins get 0 so the client schema is # flat, but the cards are mounted based on ``available_roles``) admin_approvals_pending = 0 admin_provisioning_queue = 0 admin_offboarding_blocked = 0 if is_global_admin or is_asset_admin: admin_provisioning_queue = await _scalar_count( db, select(func.count()) .select_from(ProvisioningTask) .where( ProvisioningTask.status.in_( [STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_NOTIFIED], ) ), ) if is_global_admin: admin_approvals_pending = await _scalar_count( db, select(func.count()) .select_from(ApprovalRequest) .where(ApprovalRequest.status == "pending"), ) admin_offboarding_blocked = await _scalar_count( db, # Terminal processes excluded (2026-09-05). ``cancel_process`` # flips the process to PROC_CANCELLED and deliberately leaves # blocker rows alone, while ``reconcile_blockers`` refuses # terminal processes with 409 PROCESS_TERMINAL — so those # pending rows are FROZEN: nothing will ever resolve them and # no admin action can clear them. Counting them put a badge on # the admin home that only ever grew. (Completion cannot # produce them: ``complete_process`` refuses while blockers are # pending, so cancellation is the only source.) Same # ``notin_`` definition as the leaver-blocked dashboard and # health service, so the three totals line up. select(func.count(func.distinct(OffboardingBlocker.process_id))) .select_from(OffboardingBlocker) .join( OffboardingProcess, OffboardingProcess.id == OffboardingBlocker.process_id, ) .where( OffboardingBlocker.resolution == RESO_PENDING, OffboardingProcess.status.notin_( [PROC_COMPLETED, PROC_CANCELLED], ), ), ) payload = MobileHomeResponse( user=MobileHomeUser( id=str(current_user.id), display_name=current_user.display_name or current_user.work_email or "", email=current_user.work_email or "", avatar_url=getattr(current_user, "avatar_url", None), department=getattr(current_user, "department", None), position=getattr(current_user, "job_title", None), preferred_language=getattr(current_user, "preferred_language", None), roles=role_names, ), available_roles=available_roles, is_manager=has_subordinates, counts=MobileHomeCounts( pending_todos=pending_xfers + pending_tasks, assigned_assets=assigned_assets, unread_inbox=int(inbox_summary.get("unread", 0)), # Actionable subset — drives the bell red badge (the labelled # "Unread inbox" home row keeps the honest total above). Falls # back to the total if the summary predates the split. unread_inbox_actionable=int( inbox_summary.get( "unread_actionable", inbox_summary.get("unread", 0), ) ), team_subordinates=team_subordinates, team_leavers_pending=team_leavers_pending, admin_approvals_pending=admin_approvals_pending, admin_provisioning_queue=admin_provisioning_queue, admin_offboarding_blocked=admin_offboarding_blocked, ), recent_inbox=[ MobileHomeInboxItem( id=item["id"], title=item.get("title") or "", template_key=item.get("template_key"), url=item.get("url"), read_at=item.get("read_at"), created_at=item["created_at"], ) for item in inbox_page["items"] ], pinned_apps=[ MobileAppEntry( key=a["key"], category=a["category"], title_key=a["title_key"], subtitle_key=a.get("subtitle_key"), icon=a["icon"], path=a["path"], ) for a in pinned_apps_for(role_names) ], ) return success_response(data=payload.model_dump(), request=request) @router.get("/inbox", summary="Unified cross-domain action inbox") async def mobile_action_inbox( request: Request, type: str | None = Query( None, description=( "Comma-separated item types to include (e.g. " "'approval,asset_transfer_confirm'). Omit for all types the " "caller's effective role set can see." ), ), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), as_role: str | None = Query( None, description=( "Optional RoleSwitcher perspective — narrows the action inbox " "to that role's items (mirrors the apps catalog). In the " "employee perspective an admin/auditor sees only their own " "transfers + provisioning todos, never security findings or " "approvals. Ignored if the caller doesn't hold the role." ), ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Every "waiting for me to act" item across domains, prioritised. The spine of the mobile action centre. Role-scoped off the caller's effective roles narrowed to the RoleSwitcher ``as_role`` perspective (so the employee view never leaks admin/auditor items), read-only — each item links to the page that mutates it. See :func:`app.mobile.action_inbox.build_action_inbox`. """ types = [t.strip() for t in type.split(",") if t.strip()] if type else None role_names = resolve_perspective_role_names( list(current_user.role_names or []), as_role ) payload = await build_action_inbox( db, person_id=current_user.id, role_names=role_names, types=types, page=page, page_size=page_size, ) return success_response(data=payload.model_dump(), request=request) async def _has_subordinates(db: AsyncSession, person_id: UUID) -> bool: """Cheap existence probe — we only need to know if the user has any direct report, not the actual count, at the manager-card mount decision. The follow-up :func:`_subordinate_ids` call only runs when this returns True. """ row = ( await db.execute( select(IdentityPerson.id).where( and_( IdentityPerson.manager_person_id == person_id, IdentityPerson.deleted_at.is_(None), # Match the MANAGED scope of ``_subordinate_ids`` so the # manager card never mounts for someone whose only # reports have all left (the card would then be empty). employment_status_clause(SubordinateScope.MANAGED), ) ).limit(1) ) ).scalar_one_or_none() return row is not None async def _subordinate_ids(db: AsyncSession, person_id: UUID) -> list[UUID]: # Direct reports for the mobile "my team" surface. Previously this # applied NO ``employment_status`` filter (``deleted_at IS NULL`` # only), so a departed report (``left``) lingered on the mobile team # view forever — the silent inverse of the dashboard leaver-display # bug class. Route through the canonical resolver with ``MANAGED`` # scope so ``left`` drops off while ``leaver_pending`` (still # employed, "即将离职") is kept, matching the desktop dashboard. return await subordinate_person_ids( db, person_id, scope=SubordinateScope.MANAGED, max_depth=1, ) async def _scalar_count(db: AsyncSession, stmt) -> int: """Run a ``SELECT COUNT(...)`` statement and return the scalar as ``int``. The two-step ``.scalar() or 0`` pattern is repeated enough in this module that pulling it out kills a row of boilerplate per query. """ return int((await db.execute(stmt)).scalar() or 0)