""" interface/api/routes/ppg_routes.py ──────────────────────────────────── REST endpoints for PPG signal ingestion (ETL #1). Endpoints: POST /api/v1/ppg/ingest — Receive PPG from mobile app, store + queue GET /api/v1/ppg/{user_id} — Query stored PPG signals for a user Error handling: All domain exceptions (InvalidSignalError, ConflictError, DatabaseError, BrokerError, etc.) bubble up to the global handlers registered in error_handlers.py. Route handlers are intentionally kept exception-free for readability. """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, status from src.application.dto.ppg_dto import PPGIngestRequest, PPGIngestResponse from src.application.use_cases.ingest_ppg import IngestPPGUseCase from src.interface.api.dependencies import get_ingest_ppg_use_case from src.shared.logger import get_logger logger = get_logger(__name__) router = APIRouter( prefix="/ppg", tags=["PPG Ingestion"], ) @router.post( "/ingest", response_model=PPGIngestResponse, status_code=status.HTTP_201_CREATED, summary="Ingest a PPG Signal", description=( "Receive a raw PPG signal from the mobile app (IoT sensor), validate it, " "persist it to the database, and publish it to the message queue for " "downstream AI processing (ETL #1)." ), responses={ 201: {"description": "Signal ingested and queued successfully."}, 400: { "description": "Invalid signal — domain validation failed.", "content": { "application/json": { "example": { "error": "invalid_signal", "message": "Invalid PPG signal — field 'sampling_rate': ...", "context": {"field": "sampling_rate", "value": -1, "reason": "..."}, "timestamp": "2026-05-31T09:00:00+00:00", } } }, }, 409: { "description": "Conflict — duplicate signal record.", "content": { "application/json": { "example": { "error": "conflict", "message": "Conflict on PPGModel: A record with the same unique key already exists.", "context": {}, "timestamp": "2026-05-31T09:00:00+00:00", } } }, }, 422: {"description": "Unprocessable — Pydantic request validation failed."}, 503: { "description": "Service unavailable — database or broker is down.", "content": { "application/json": { "example": { "error": "database_unavailable", "message": "The database is temporarily unavailable.", "context": {}, "timestamp": "2026-05-31T09:00:00+00:00", } } }, }, }, ) async def ingest_ppg( request: PPGIngestRequest, use_case: Annotated[IngestPPGUseCase, Depends(get_ingest_ppg_use_case)], ) -> PPGIngestResponse: """ **ETL #1 — Entry Point** Accepts a JSON payload from the smartphone, validates it against domain rules, stores the raw PPG in PostgreSQL (Supabase), and publishes an event to RabbitMQ for the AI processing consumer (ETL #2). Returns the stored signal ID and queue confirmation. **Possible errors:** - `400 invalid_signal` — sampling rate out of range, signal too short, etc. - `409 conflict` — duplicate signal ID submitted. - `503 database_unavailable` — Supabase is temporarily unreachable. - `503 broker_unavailable` — RabbitMQ/CloudAMQP is temporarily unreachable. """ logger.info( "POST /ppg/ingest — device=%s user=%s", request.device_id, request.user_id, ) return await use_case.execute(request)