"""Mobile app API endpoints. Provides endpoints specifically designed for the DocuElevate native mobile app (iOS / Android via React Native / Expo): * ``POST /mobile/generate-token`` – exchange an active session for a long-lived API token that the mobile app stores securely. The token is auto-named "Mobile App – " and is identical to regular API tokens (Bearer auth works everywhere). * ``POST /mobile/register-device`` – register a push-notification device token (Expo push token) so the user receives push notifications when documents finish processing. * ``GET /mobile/devices`` – list registered devices for the current user. * ``DELETE /mobile/devices/{device_id}`` – deactivate a device. * ``GET /mobile/whoami`` – lightweight profile endpoint for the mobile app to verify authentication state. """ import logging from datetime import datetime, timezone from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.api_tokens import generate_api_token, hash_token from app.auth import require_login from app.database import get_db from app.models import ApiToken, MobileDevice from app.utils.user_scope import get_current_owner_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/mobile", tags=["mobile"]) DbSession = Annotated[Session, Depends(get_db)] # --------------------------------------------------------------------------- # Auth helper # --------------------------------------------------------------------------- def _get_owner_id(request: Request) -> str: """Return the current user's owner ID, raising 401 if unauthenticated.""" owner_id = get_current_owner_id(request) if not owner_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") return owner_id CurrentOwner = Annotated[str, Depends(_get_owner_id)] # --------------------------------------------------------------------------- # Request / Response schemas # --------------------------------------------------------------------------- class GenerateTokenRequest(BaseModel): """Request body for auto-generating a mobile app token.""" device_name: str = Field( default="Mobile App", min_length=1, max_length=120, description="Human-readable device name used to label the token.", ) class GenerateTokenResponse(BaseModel): """Response containing the one-time-visible API token.""" token: str token_id: int name: str created_at: datetime class RegisterDeviceRequest(BaseModel): """Request body for registering a push-notification device token.""" push_token: str = Field( min_length=1, max_length=512, description="Expo push token (ExponentPushToken[…]) obtained from the mobile app.", ) device_name: str | None = Field( default=None, max_length=255, description="Optional human-readable device name (e.g. 'John's iPhone').", ) platform: str = Field( default="ios", description="Device platform: 'ios', 'android', or 'web'.", ) class DeviceResponse(BaseModel): """Serialised MobileDevice record.""" id: int device_name: str | None platform: str push_token_preview: str is_active: bool created_at: datetime last_seen_at: datetime | None class WhoAmIResponse(BaseModel): """Lightweight profile response for the mobile app.""" owner_id: str display_name: str | None email: str | None avatar_url: str | None is_admin: bool preferred_language: str | None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _device_to_response(device: MobileDevice) -> dict[str, Any]: """Convert a MobileDevice ORM object to a serialisable dict.""" # Show only first 20 chars of the push token for security. token_preview = device.push_token[:20] + "…" if len(device.push_token) > 20 else device.push_token return { "id": device.id, "device_name": device.device_name, "platform": device.platform, "push_token_preview": token_preview, "is_active": device.is_active, "created_at": device.created_at, "last_seen_at": device.last_seen_at, } # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.post("/generate-token", status_code=status.HTTP_201_CREATED, response_model=GenerateTokenResponse) @require_login async def generate_mobile_token( request: Request, body: GenerateTokenRequest, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, Any]: """Generate a long-lived API token for the mobile app. The mobile app calls this endpoint immediately after SSO login to obtain a Bearer token it can store in the secure keychain. The returned token is functionally identical to manually-created API tokens and works with every authenticated endpoint. The token is shown **exactly once** in the response; subsequent requests show only the prefix for identification. """ token_name = f"Mobile App – {body.device_name}" plaintext = generate_api_token() token_hash_value = hash_token(plaintext) prefix = plaintext[:12] db_token = ApiToken( owner_id=owner_id, name=token_name, token_hash=token_hash_value, token_prefix=prefix, ) try: db.add(db_token) db.commit() db.refresh(db_token) except Exception: db.rollback() logger.exception("Failed to create mobile API token for owner_id=%s", owner_id) raise logger.info("Mobile API token created: id=%s owner=%s device=%r", db_token.id, owner_id, body.device_name) return { "token": plaintext, "token_id": db_token.id, "name": token_name, "created_at": db_token.created_at, } @router.post("/register-device", status_code=status.HTTP_201_CREATED, response_model=DeviceResponse) @require_login async def register_device( request: Request, body: RegisterDeviceRequest, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, Any]: """Register or refresh a push-notification device token. If the same ``push_token`` is already registered for this user the record is reactivated and ``last_seen_at`` is updated rather than creating a duplicate. """ platform = body.platform.lower() if platform not in {"ios", "android", "web"}: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="platform must be one of: ios, android, web", ) now = datetime.now(timezone.utc) # Upsert: reuse existing record if the token is already known. existing = ( db.query(MobileDevice) .filter(MobileDevice.owner_id == owner_id, MobileDevice.push_token == body.push_token) .first() ) if existing: existing.is_active = True existing.last_seen_at = now if body.device_name: existing.device_name = body.device_name try: db.commit() db.refresh(existing) except Exception: db.rollback() raise logger.info("Mobile device refreshed: id=%s owner=%s", existing.id, owner_id) return _device_to_response(existing) device = MobileDevice( owner_id=owner_id, device_name=body.device_name, platform=platform, push_token=body.push_token, is_active=True, last_seen_at=now, ) try: db.add(device) db.commit() db.refresh(device) except Exception: db.rollback() logger.exception("Failed to register mobile device for owner_id=%s", owner_id) raise logger.info("Mobile device registered: id=%s owner=%s platform=%s", device.id, owner_id, platform) return _device_to_response(device) @router.get("/devices", response_model=list[DeviceResponse]) @require_login async def list_devices( request: Request, owner_id: CurrentOwner, db: DbSession, ) -> list[dict[str, Any]]: """List all registered push-notification devices for the current user.""" devices = ( db.query(MobileDevice).filter(MobileDevice.owner_id == owner_id).order_by(MobileDevice.created_at.desc()).all() ) return [_device_to_response(d) for d in devices] @router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK) @require_login async def deactivate_device( request: Request, device_id: int, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, str]: """Deactivate or permanently delete a push-notification device registration. * **Active device** – soft-deactivated: the record is kept for audit purposes but will no longer receive push notifications. * **Already-inactive device** – hard-deleted: the record is permanently removed from the database. """ device = db.get(MobileDevice, device_id) if not device or device.owner_id != owner_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") if device.is_active: device.is_active = False try: db.commit() except Exception: db.rollback() raise logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id) return {"detail": "Device deactivated"} # Hard-delete an already-inactive device. try: db.delete(device) db.commit() except Exception: db.rollback() raise logger.info("Mobile device permanently deleted: id=%s owner=%s", device_id, owner_id) return {"detail": "Device deleted"} @router.get("/whoami", response_model=WhoAmIResponse) @require_login async def whoami( request: Request, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, Any]: """Return basic profile information for the authenticated user. The mobile app calls this after token exchange to populate the user profile screen and verify that the stored token is still valid. """ from app.auth import get_gravatar_url from app.models import LocalUser, UserProfile profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first() local_user = db.query(LocalUser).filter(LocalUser.email == owner_id).first() display_name: str | None = None email: str | None = None avatar_url: str | None = None is_admin = False if profile: display_name = profile.display_name if local_user: email = local_user.email is_admin = bool(local_user.is_admin) if not display_name and local_user.display_name: display_name = local_user.display_name elif "@" in owner_id: # SSO users commonly have their email as owner_id email = owner_id if email: avatar_url = get_gravatar_url(email) return { "owner_id": owner_id, "display_name": display_name, "email": email, "avatar_url": avatar_url, "is_admin": is_admin, "preferred_language": profile.preferred_language if profile else None, }