"""Pydantic models for the mobile (``/api/v1/m/*``) API surface. These shapes are deliberately compact — every byte counts on a Feishu in-app webview over a mobile network. Fields the client doesn't need yet are NOT pre-emptively added; extend the schema as M-Mobile milestones bring in new UX needs. """ from __future__ import annotations from pydantic import BaseModel, Field class MobileAppCategory(BaseModel): """A grouping bucket in the ``/m/apps`` grid (My / Team / Asset / ...).""" key: str title_key: str order: int class MobileAppEntry(BaseModel): """One navigable destination in the mobile app grid. ``path`` is the in-app route (``/m/...``). ``title_key`` / ``subtitle_key`` are i18next lookup keys — frontend translates via the same dictionary it uses for desktop. ``icon`` names a registered icon in the frontend's icon map. """ key: str category: str title_key: str subtitle_key: str | None = None icon: str path: str class MobileAppsCatalogResponse(BaseModel): """Response body of ``GET /m/apps/catalog``. ``categories`` is the canonical ordering of grid sections. ``apps`` is filtered to entries the caller's role set is allowed to see — callers must NOT trust this for authz on the destination route itself; the backing endpoint still enforces its own gate. ``pinned_default`` lists keys the workspace home should surface as shortcuts before the user has set their own picks. """ categories: list[MobileAppCategory] apps: list[MobileAppEntry] pinned_default: list[str] class MobileHomeUser(BaseModel): """Identity snapshot used by the workspace header. Same fields ``GET /auth/me`` returns, but flattened into one block so the client doesn't pay a second round-trip. """ id: str display_name: str email: str avatar_url: str | None = None department: str | None = None position: str | None = None preferred_language: str | None = None roles: list[str] class MobileHomeCounts(BaseModel): """Numeric badges surfaced on the workspace home cards. A field is ``0`` when the user holds the relevant role but has nothing to do; counts that don't apply to the user (admin counts for a non-admin) are also ``0`` so the schema stays flat for the client. The client decides which cards to render based on ``available_roles``, not on whether a count is non-zero. """ # ── Employee (always populated) ── pending_todos: int = 0 assigned_assets: int = 0 unread_inbox: int = 0 # Unread MINUS social/ceremonial greetings — drives the bell red badge. unread_inbox_actionable: int = 0 # ── Manager (populated iff user has subordinates) ── team_subordinates: int = 0 team_leavers_pending: int = 0 # ── Admin (populated iff user holds global_admin / asset_admin) ── admin_approvals_pending: int = 0 admin_provisioning_queue: int = 0 admin_offboarding_blocked: int = 0 class MobileHomeInboxItem(BaseModel): """Compact inbox row for the "recent notifications" strip on home.""" id: str title: str template_key: str | None = None url: str | None = None read_at: str | None = None created_at: str class MobileHomeResponse(BaseModel): """Response body of ``GET /m/aggregate/home``. ``available_roles`` is the set of *switchable* perspectives the user holds — their stored DB roles plus the implicit ``"employee"`` baseline. It drives the RoleSwitcher dropdown. ``"manager"`` is NOT a switchable perspective (aligned with desktop 2026-06-15, which removed it from the view-role switcher): being a manager is a derived *fact* surfaced as ``is_manager``, not a role you flip into. The client mounts the team card / team tab off ``is_manager``, orthogonal to the active perspective. """ user: MobileHomeUser available_roles: list[str] = Field( description=( "Switchable RoleSwitcher perspectives: the caller's stored DB " "roles plus the implicit 'employee' baseline. Does NOT include " "'manager' — manager status is the derived 'is_manager' fact." ), ) is_manager: bool = Field( default=False, description=( "True when the caller has at least one live direct report " "(MANAGED scope — excludes already-left reports). Derived from " "org structure, not a stored role. Gates the team card / team " "tab independent of the active perspective." ), ) counts: MobileHomeCounts recent_inbox: list[MobileHomeInboxItem] pinned_apps: list[MobileAppEntry] = Field( default_factory=list, description=( "Role-default pinned app entries (visible to the caller) for " "the home shortcut row — the '我的东西' quick-access zone. " "Rendered directly (title_key / icon / path) so the home needs " "no separate /m/apps/catalog round-trip." ), ) # ── Unified action inbox (``GET /m/aggregate/inbox``) ── # # The action inbox is the spine of the mobile "行动中心" (action centre): # every "something is waiting for ME to act on it" item, regardless of # which domain owns it, collapsed into one prioritised, paginated list. # This is a READ aggregation only — each item carries a ``deep_link`` to # the page that can actually mutate it, plus optional inline ``actions`` # the client can render as quick buttons. The aggregator never mutates # and never becomes the source of truth; it reads each domain's own # pending state. New item types extend ``build_action_inbox`` — the # schema below is type-agnostic so adding a source needs no schema bump. class MobileActionDescriptor(BaseModel): """One inline quick-action a client may render on an inbox row. ``key`` is a stable verb the client maps to both an i18n label (``mobile.inbox.action.``) and the mutation it fires. ``style`` drives the button colour. The list is advisory: every item is always actionable by tapping through to ``deep_link``; ``actions`` is the subset safe to expose as one-tap buttons in the list itself. """ key: str style: str = "default" # primary | default | danger class MobileActionItem(BaseModel): """One actionable item in the unified inbox. ``id`` is ``"{type}:{entity_id}"`` — stable across refreshes so the client can de-dup / animate. ``title`` / ``subtitle`` carry data-derived strings (asset label, finding title, …); enum-shaped values (a provisioning ``action``, an ``approval_type``) are passed through verbatim and the client maps them to localised labels via the same i18n dictionaries the desktop uses. ``severity`` drives both sort order and the colour dot. """ id: str type: str title: str subtitle: str | None = None severity: str = "info" # urgent | warning | info deadline: str | None = None created_at: str deep_link: str actions: list[MobileActionDescriptor] = Field(default_factory=list) class MobileActionInboxResponse(BaseModel): """Response body of ``GET /m/aggregate/inbox``. ``counts`` is the per-type candidate count BEFORE pagination (so the UI can badge "12 approvals →" without a second call); ``total`` is the sum across surfaced types for the caller's effective role set. Per-source results are capped (see ``build_action_inbox``) — the dedicated per-domain pages remain the full list when a queue is huge. """ items: list[MobileActionItem] counts: dict[str, int] total: int page: int page_size: int