"""Manager-facing onboarding endpoints. PR-3 of the symmetric-onboarding series (2026-06-01). Mirrors offboarding's ``/my/team-leavers`` pattern: a manager (direct or skip-level) sees and acts on their team's onboarding processes without holding admin role. Endpoints (under ``/api/v1/my/team-joiners``): * ``GET /`` — list incoming hires * ``PUT /{process_id}/hardware-allocation`` — set per-slot source map All endpoints require the caller to be the joiner's direct or skip- level manager (verified via ``_viewer_can_manage_process``). They share the same business logic as the admin endpoints under ``/api/v1/onboarding/*``; the only difference is the auth gate. """ from __future__ import annotations from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import AppException from app.core.response import success_response from app.domains.onboarding.service import OnboardingService router = APIRouter( prefix="/api/v1/my/team-joiners", tags=["onboarding", "team"], ) @router.get("") async def list_my_team_joiners( request: Request, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Incoming hires the caller manages (direct + skip-level).""" items, total = await OnboardingService(db).list_my_team_joiners( current_user.id, page=page, page_size=page_size, ) return success_response( data=items, meta={"total": int(total), "page": page, "page_size": page_size}, request=request, ) async def _gate_manager( process_id: UUID, user_id: UUID, db: AsyncSession, ) -> None: svc = OnboardingService(db) proc = await svc._get_or_raise(process_id) if not await svc._viewer_can_manage_process(user_id, proc): raise AppException( code="NOT_MANAGER_OF_HIRE", message="caller is not the manager (direct or skip-level) of this hire", status_code=403, ) class HardwareAllocationBody(BaseModel): allocation: dict[str, Any] | None = Field(default=None) @router.put("/{process_id}/hardware-allocation") async def set_team_joiner_hardware_allocation( process_id: UUID, body: HardwareAllocationBody, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await _gate_manager(process_id, current_user.id, db) trace_id = getattr(request.state, "trace_id", str(process_id)) proc = await OnboardingService(db).set_hardware_allocation( process_id, body.allocation, trace_id=trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response( data={ "id": str(proc.id), "hardware_allocation": proc.hardware_allocation, }, request=request, )