""" application/use_cases/ingest_ppg.py ───────────────────────────────────── IngestPPGUseCase — ETL #1 orchestrator. Responsibility: Accept a raw PPG payload from the API, validate it, persist it to the database, and publish a message to the queue for downstream processing. Dependencies (injected via constructor — Dependency Inversion Principle): • PPGRepository — stores the signal • MessageBroker — publishes the event This use case knows nothing about FastAPI, SQLAlchemy, or RabbitMQ. """ from __future__ import annotations import json from src.application.dto.ppg_dto import PPGIngestRequest, PPGIngestResponse from src.domain.entities.ppg_signal import PPGSignal from src.domain.interfaces.repositories.ppg_repository import PPGRepository from src.domain.interfaces.services.message_broker import MessageBroker from src.shared.constants import PPG_QUEUE_NAME from src.shared.logger import get_logger logger = get_logger(__name__) class IngestPPGUseCase: """ ETL #1: Extract → Transform → Load for incoming PPG signals. Steps: 1. Extract — build a validated PPGSignal entity from the request DTO. 2. Transform — call ``entity.validate()`` to enforce domain rules. 3. Load — persist to database (``ppg_repo.add()``) publish to queue (``broker.publish()``). Usage:: use_case = IngestPPGUseCase(ppg_repo=..., broker=...) response = await use_case.execute(request) """ def __init__( self, ppg_repo: PPGRepository, broker: MessageBroker, ) -> None: """ Args: ppg_repo: Any concrete PPGRepository (SQLAlchemy, in-memory, …). broker: Any concrete MessageBroker (RabbitMQ, in-memory, …). """ self._ppg_repo = ppg_repo self._broker = broker async def execute(self, request: PPGIngestRequest) -> PPGIngestResponse: """ Run the ingestion pipeline for a single PPG signal. Args: request: Validated Pydantic DTO from the API route. Returns: PPGIngestResponse with the stored signal ID and queue status. Raises: InvalidSignalError: If the signal fails domain validation. """ logger.info( "IngestPPGUseCase.execute — device=%s user=%s samples=%d", request.device_id, request.user_id, len(request.ppg_values), ) # ── Step 1: EXTRACT — Build domain entity ───────────────────────────── signal = PPGSignal( device_id=request.device_id, user_id=request.user_id, sampling_rate=request.sampling_rate, ppg_values=request.ppg_values, duration_seconds=request.duration_seconds, timestamp=request.timestamp, ) # ── Step 2: TRANSFORM — Domain validation ──────────────────────────── signal.validate() logger.debug("Signal validated: %s", signal) # ── Step 3a: LOAD — Persist to database ────────────────────────────── persisted_signal = await self._ppg_repo.add(signal) logger.info("Signal persisted with id=%s", persisted_signal.id) # ── Step 3b: LOAD — Publish to message queue ───────────────────────── queued = False try: await self._broker.publish( queue_name=PPG_QUEUE_NAME, message=persisted_signal.to_dict(), ) queued = True logger.info("Signal %s published to queue '%s'", persisted_signal.id, PPG_QUEUE_NAME) except Exception as exc: # Publishing failure is non-fatal: signal is already persisted. # Log the error so the ops team can replay the message if needed. logger.error( "Failed to publish signal %s to queue: %s", persisted_signal.id, exc, exc_info=True, ) return PPGIngestResponse( signal_id=persisted_signal.id, user_id=persisted_signal.user_id, device_id=persisted_signal.device_id, num_samples=persisted_signal.num_samples, duration_seconds=persisted_signal.duration_seconds, queued=queued, )