diff --git a/backend/alembic/versions/109_patient_only_treatments.py b/backend/alembic/versions/109_patient_only_treatments.py new file mode 100644 index 0000000..3998049 --- /dev/null +++ b/backend/alembic/versions/109_patient_only_treatments.py @@ -0,0 +1,46 @@ +"""make professional treatment fields optional for patient-only v2 + +Revision ID: 109_patient_only_treatments +Revises: 108_patient_health_appointments +Create Date: 2026-06-07 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "109_patient_only_treatments" +down_revision: str | None = "108_patient_health_appointments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column("treatments", "prescribed_by", nullable=True) + + +def downgrade() -> None: + op.execute( + """ + UPDATE treatments t + SET prescribed_by = hp.id + FROM ( + SELECT id FROM health_professionals + WHERE deleted_at IS NULL + ORDER BY created_at + LIMIT 1 + ) hp + WHERE t.prescribed_by IS NULL + """ + ) + bind = op.get_bind() + null_count = bind.execute( + sa.text("SELECT COUNT(*) FROM treatments WHERE prescribed_by IS NULL") + ).scalar_one() + if null_count != 0: + raise RuntimeError( + "Cannot downgrade to required treatments.prescribed_by while treatments without " + "a prescriber exist and no health professional is available to backfill them." + ) + op.alter_column("treatments", "prescribed_by", nullable=False) diff --git a/backend/alembic/versions/110_unique_active_treatment.py b/backend/alembic/versions/110_unique_active_treatment.py new file mode 100644 index 0000000..5158fbe --- /dev/null +++ b/backend/alembic/versions/110_unique_active_treatment.py @@ -0,0 +1,32 @@ +"""partial unique index: one active treatment per patient + +Revision ID: 110_unique_active_treatment +Revises: 109_patient_only_treatments +Create Date: 2026-06-07 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "110_unique_active_treatment" +down_revision: str | None = "109_patient_only_treatments" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_index( + "uq_treatments_one_active_per_patient", + "treatments", + ["patient_id"], + unique=True, + postgresql_where="status = 'active' AND deleted_at IS NULL", + ) + + +def downgrade() -> None: + op.drop_index( + "uq_treatments_one_active_per_patient", + table_name="treatments", + ) diff --git a/backend/alembic/versions/111_create_journey_events.py b/backend/alembic/versions/111_create_journey_events.py new file mode 100644 index 0000000..3424715 --- /dev/null +++ b/backend/alembic/versions/111_create_journey_events.py @@ -0,0 +1,64 @@ +"""create persisted journey events + +Revision ID: 111_create_journey_events +Revises: 110_unique_active_treatment +Create Date: 2026-06-08 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision: str = "111_create_journey_events" +down_revision: str | None = "110_unique_active_treatment" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "journey_events", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "patient_id", + UUID(as_uuid=True), + sa.ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "treatment_id", + UUID(as_uuid=True), + sa.ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("event_type", sa.String(50), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("metadata", JSONB(), server_default="{}", nullable=False), + sa.Column("source_type", sa.String(50), nullable=True), + sa.Column("source_id", UUID(as_uuid=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.UniqueConstraint("source_type", "source_id", name="uq_journey_events_source"), + ) + op.create_index( + "ix_journey_events_patient_occurred_at", + "journey_events", + ["patient_id", "occurred_at"], + ) + op.create_index("ix_journey_events_treatment_id", "journey_events", ["treatment_id"]) + op.create_index("ix_journey_events_event_type", "journey_events", ["event_type"]) + + +def downgrade() -> None: + op.drop_index("ix_journey_events_event_type", table_name="journey_events") + op.drop_index("ix_journey_events_treatment_id", table_name="journey_events") + op.drop_index("ix_journey_events_patient_occurred_at", table_name="journey_events") + op.drop_table("journey_events") diff --git a/backend/bruno/dose/register_dose.bru b/backend/bruno/dose/register_dose.bru index b3525a0..6957684 100644 --- a/backend/bruno/dose/register_dose.bru +++ b/backend/bruno/dose/register_dose.bru @@ -1,5 +1,5 @@ meta { - name: Register Dose + name: Register Dose (v1) type: http seq: 1 } @@ -24,7 +24,6 @@ body:json { "expected_at": "2026-02-15T08:00:00Z", "taken_at": "2026-02-15T08:30:00Z", "skipped": false, - "skip_reason": null, "supervised": false } } @@ -34,37 +33,10 @@ assert { res.body.id: isDefined res.body.treatment_id: eq "{{treatmentId}}" res.body.drug_name: eq "Dapsona" - res.body.skipped: eq false res.body.supervised: eq false - res.body.created_at: isDefined } docs { - Registra uma dose (tomada, pulada ou supervisionada) para o tratamento. - - Regras de negócio: - - Paciente: doses diárias com `supervised: false`. - - Paciente: dose supervisionada somente com `supervised: true` e `via_consultation: true` - (após registrar consulta realizada no app). - - Profissional: pode registrar doses supervisionadas (`supervised: true`) - com `registered_by` preenchido automaticamente. - - Dose duplicada (mesmo `drug_name` + `expected_at` + tratamento) retorna 409. - - Rate limit: 20/minuto. - - Exemplo de dose supervisionada (para profissional): - { - "drug_name": "Rifampicina", - "expected_at": "2026-02-01T09:00:00Z", - "taken_at": "2026-02-01T09:15:00Z", - "supervised": true - } - - Exemplo de dose pulada: - { - "drug_name": "Dapsona", - "expected_at": "2026-02-16T08:00:00Z", - "skipped": true, - "skip_reason": "Paciente relatou náusea intensa." - } + Contrato legado v1 — paciente ou profissional. + Preferir v2 para fluxo patient-first. } diff --git a/backend/bruno/dose/register_dose_v2.bru b/backend/bruno/dose/register_dose_v2.bru new file mode 100644 index 0000000..2b7f45c --- /dev/null +++ b/backend/bruno/dose/register_dose_v2.bru @@ -0,0 +1,35 @@ +meta { + name: Register Dose (v2 patient) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v2/treatments/{{treatmentId}}/doses + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "drug_name": "Dapsona", + "expected_at": "2026-02-15T08:00:00Z", + "taken_at": "2026-02-15T08:30:00Z", + "skipped": false + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.drug_name: eq "Dapsona" +} diff --git a/backend/bruno/journey/get_journey.bru b/backend/bruno/journey/get_journey.bru new file mode 100644 index 0000000..1017d4a --- /dev/null +++ b/backend/bruno/journey/get_journey.bru @@ -0,0 +1,29 @@ +meta { + name: Get Journey v2 + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/v2/journey + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.patient.id: isDefined + res.body.treatment.id: isDefined + res.body.summary.total_months: isDefined + res.body.summary.total_consultations: isDefined + res.body.summary.total_doses_registered: isDefined + res.body.months[0].month_number: isDefined + res.body.months[0].is_current: isDefined +} + +docs { + Alias v2 da jornada do paciente autenticado. +} diff --git a/backend/bruno/journey/get_patient_journey.bru b/backend/bruno/journey/get_patient_journey.bru new file mode 100644 index 0000000..97e7fbd --- /dev/null +++ b/backend/bruno/journey/get_patient_journey.bru @@ -0,0 +1,34 @@ +meta { + name: Get Patient Journey + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/patients/me/journey + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.patient.id: isDefined + res.body.treatment.id: isDefined + res.body.summary.total_months: isDefined + res.body.summary.current_month: isDefined + res.body.summary.total_consultations: isDefined + res.body.summary.total_doses_registered: isDefined + res.body.months: isDefined + res.body.months[0].month_number: isDefined + res.body.months[0].is_current: isDefined +} + +docs { + Retorna a jornada do paciente autenticado com meses em ordem decrescente. + + Eventos persistidos suportam doses e futuras origens clinicas geradas por workers. + Rate limit: 100/minuto por paciente. +} diff --git a/backend/bruno/treatment/create_treatment.bru b/backend/bruno/treatment/create_treatment.bru index 24a5b1b..a7ce2e4 100644 --- a/backend/bruno/treatment/create_treatment.bru +++ b/backend/bruno/treatment/create_treatment.bru @@ -1,5 +1,5 @@ meta { - name: Create Treatment + name: Create Treatment (v1 professional) type: http seq: 1 } @@ -38,12 +38,6 @@ assert { } docs { - Cria um novo tratamento MDT para o paciente informado. - - Apenas profissionais de saúde autenticados podem chamar este endpoint. - O campo `expected_end` é calculado automaticamente: - - PB → start_date + 6 meses - - MB → start_date + 12 meses - - Rate limit: 10/minuto por profissional. + Contrato legado v1 — apenas profissionais autenticados. + Preferir v2 para fluxo patient-first. } diff --git a/backend/bruno/treatment/create_treatment_v2.bru b/backend/bruno/treatment/create_treatment_v2.bru new file mode 100644 index 0000000..249f7bb --- /dev/null +++ b/backend/bruno/treatment/create_treatment_v2.bru @@ -0,0 +1,36 @@ +meta { + name: Create Treatment (v2 patient) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v2/treatments + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +headers { + Content-Type: application/json +} + +body:json { + { + "regimen": "PB", + "start_date": "2026-01-15", + "notes": "Tratamento PB iniciado pelo paciente." + } +} + +assert { + res.status: eq 201 + res.body.id: isDefined + res.body.patient_id: isDefined + res.body.regimen: eq "PB" + res.body.expected_end: isDefined + res.body.status: eq "active" +} diff --git a/backend/bruno/treatment/get_adherence_v2.bru b/backend/bruno/treatment/get_adherence_v2.bru new file mode 100644 index 0000000..dd19828 --- /dev/null +++ b/backend/bruno/treatment/get_adherence_v2.bru @@ -0,0 +1,37 @@ +meta { + name: Get Adherence Snapshot (v2 patient) + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/v2/treatments/{{treatmentId}}/adherence + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: isDefined + res.body.treatment_id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.period_start: isDefined + res.body.period_end: isDefined + res.body.total_doses: isDefined + res.body.taken_doses: isDefined + res.body.adherence_pct: isDefined + res.body.calculated_at: isDefined +} + +docs { + Retorna o snapshot de adesão mais recente para o tratamento v2. + + IMPORTANTE: A adesão NUNCA é calculada em tempo real. + Este endpoint lê exclusivamente de `adherence_snapshots`. + + Acessível apenas pelo paciente dono do tratamento. + Rate limit: 100/minuto. +} diff --git a/backend/bruno/treatment/get_treatment_v2.bru b/backend/bruno/treatment/get_treatment_v2.bru new file mode 100644 index 0000000..09079c6 --- /dev/null +++ b/backend/bruno/treatment/get_treatment_v2.bru @@ -0,0 +1,31 @@ +meta { + name: Get Treatment (v2 patient) + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/v2/treatments/{{treatmentId}} + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +assert { + res.status: eq 200 + res.body.id: eq "{{treatmentId}}" + res.body.patient_id: isDefined + res.body.regimen: isDefined + res.body.status: isDefined + res.body.start_date: isDefined + res.body.expected_end: isDefined +} + +docs { + Retorna os dados de um tratamento v2 pelo ID. + + Acessível apenas pelo paciente dono do tratamento. + Rate limit: 100/minuto. +} diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 3bd510a..7f40ccc 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -77,13 +77,26 @@ async def health_check() -> JSONResponse: from pequi.routers import body_map as body_map_router from pequi.routers import checkin as checkin_router from pequi.routers import community as community_router + from pequi.routers import journey as journey_router from pequi.routers import patient as patient_router from pequi.routers import treatment as treatment_router + from pequi.routers import treatment_v2 as treatment_v2_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) + app.include_router( + journey_router.router, + prefix="/v1/patients/me/journey", + tags=["journey"], + ) + app.include_router(journey_router.router, prefix="/v2/journey", tags=["journey"]) app.include_router(account_router.router, prefix="/v1/account", tags=["account"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) app.include_router(treatment_router.router, prefix="/v1/treatments", tags=["treatments"]) + app.include_router( + treatment_v2_router.router, + prefix="/v2/treatments", + tags=["treatments-v2"], + ) app.include_router(treatment_router.symptoms_router, prefix="/v1/symptoms", tags=["symptoms"]) app.include_router(checkin_router.router, prefix="/v1/checkins", tags=["checkins"]) app.include_router(checkin_router.alerts_router, prefix="/v1/alerts", tags=["alerts"]) diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index f28561b..3818323 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -15,6 +15,7 @@ from pequi.models.health_appointment import PatientHealthAppointment from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.symptom import Symptom from pequi.models.treatment import DoseSchedule, Treatment @@ -43,6 +44,7 @@ "HealthProfessional", "PatientHealthAppointment", "HealthUnit", + "JourneyEvent", "PatientProfile", "Symptom", "Treatment", diff --git a/backend/src/pequi/models/journey_event.py b/backend/src/pequi/models/journey_event.py new file mode 100644 index 0000000..269bb9d --- /dev/null +++ b/backend/src/pequi/models/journey_event.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy import Column, DateTime, ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class JourneyEvent(Base): + """Evento unificado e persistido da jornada do paciente.""" + + __tablename__ = "journey_events" + __table_args__ = ( + UniqueConstraint("source_type", "source_id", name="uq_journey_events_source"), + Index("ix_journey_events_patient_occurred_at", "patient_id", "occurred_at"), + Index("ix_journey_events_treatment_id", "treatment_id"), + Index("ix_journey_events_event_type", "event_type"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + treatment_id = Column( + UUID(as_uuid=True), + ForeignKey("treatments.id", ondelete="RESTRICT"), + nullable=True, + ) + event_type = Column(String(50), nullable=False) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + occurred_at = Column(DateTime(timezone=True), nullable=False) + event_metadata = Column("metadata", JSONB, nullable=False, default=dict, server_default="{}") + source_type = Column(String(50), nullable=True) + source_id = Column(UUID(as_uuid=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/src/pequi/models/treatment.py b/backend/src/pequi/models/treatment.py index 5415d93..8928e1f 100644 --- a/backend/src/pequi/models/treatment.py +++ b/backend/src/pequi/models/treatment.py @@ -2,7 +2,18 @@ from decimal import Decimal from enum import StrEnum -from sqlalchemy import Column, Date, DateTime, Enum, ForeignKey, Index, Numeric, SmallInteger, Text +from sqlalchemy import ( + Column, + Date, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + SmallInteger, + Text, + text, +) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql import func @@ -45,6 +56,12 @@ class Treatment(Base): Index("ix_treatments_prescribed_by", "prescribed_by"), Index("ix_treatments_status", "status"), Index("ix_treatments_deleted_at", "deleted_at"), + Index( + "uq_treatments_one_active_per_patient", + "patient_id", + unique=True, + postgresql_where=text("status = 'active' AND deleted_at IS NULL"), + ), ) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -56,7 +73,7 @@ class Treatment(Base): prescribed_by = Column( UUID(as_uuid=True), ForeignKey("health_professionals.id", ondelete="RESTRICT"), - nullable=False, + nullable=True, ) regimen = Column( Enum(TreatmentRegimen, name="treatment_regimen_enum"), diff --git a/backend/src/pequi/repositories/account_repo.py b/backend/src/pequi/repositories/account_repo.py index e3a9586..0e64d43 100644 --- a/backend/src/pequi/repositories/account_repo.py +++ b/backend/src/pequi/repositories/account_repo.py @@ -12,6 +12,7 @@ from pequi.models.consent import Consent from pequi.models.data_deletion import DataDeletionRequest, DataDeletionStatus from pequi.models.dose_log import AdherenceSnapshot, DoseLog +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.treatment import Treatment, TreatmentStatus from pequi.models.user import User @@ -99,6 +100,14 @@ async def list_adherence_snapshots(self, patient_id: UUID) -> list[AdherenceSnap ) return list((await self._session.execute(stmt)).scalars().all()) + async def list_journey_events(self, patient_id: UUID) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where(JourneyEvent.patient_id == patient_id) + .order_by(JourneyEvent.occurred_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + async def list_weekly_symptom_summaries(self, patient_id: UUID) -> list[WeeklySymptomSummary]: stmt = ( select(WeeklySymptomSummary) diff --git a/backend/src/pequi/repositories/dose_repo.py b/backend/src/pequi/repositories/dose_repo.py index 43ff975..547575a 100644 --- a/backend/src/pequi/repositories/dose_repo.py +++ b/backend/src/pequi/repositories/dose_repo.py @@ -2,23 +2,42 @@ from uuid import UUID from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from pequi.core.exceptions import ConflictError from pequi.core.logging import get_logger from pequi.models.dose_log import DoseLog logger = get_logger(__name__) +_DOSE_DEDUP_CONSTRAINT = "uq_dose_logs_dedup" + class DoseRepository: def __init__(self, session: AsyncSession) -> None: self._session = session async def create(self, dose_log: DoseLog) -> DoseLog: - self._session.add(dose_log) - await self._session.flush() - await self._session.refresh(dose_log) - return dose_log + try: + async with self._session.begin_nested(): + self._session.add(dose_log) + await self._session.flush() + await self._session.refresh(dose_log) + return dose_log + except IntegrityError as exc: + if _constraint_violated(exc, _DOSE_DEDUP_CONSTRAINT): + logger.warning( + "duplicate_dose_attempt", + treatment_id=str(dose_log.treatment_id), + drug_name=dose_log.drug_name, + expected_at=dose_log.expected_at.isoformat(), + ) + raise ConflictError( + f"Dose duplicada: já existe registro para '{dose_log.drug_name}' " + f"em {dose_log.expected_at.isoformat()} neste tratamento." + ) from exc + raise async def exists_duplicate( self, @@ -33,15 +52,7 @@ async def exists_duplicate( DoseLog.expected_at == expected_at, ) result = await self._session.execute(stmt) - duplicate = result.scalar_one_or_none() is not None - if duplicate: - logger.warning( - "duplicate_dose_attempt", - treatment_id=str(treatment_id), - drug_name=drug_name, - expected_at=expected_at.isoformat(), - ) - return duplicate + return result.scalar_one_or_none() is not None async def list_by_treatment(self, treatment_id: UUID) -> list[DoseLog]: stmt = ( @@ -73,3 +84,14 @@ async def count_missed_doses_in_week(self, patient_id: UUID) -> int: ) result = await self._session.execute(stmt) return result.scalar_one() + + +def _constraint_violated(exc: IntegrityError, constraint_name: str) -> bool: + orig = getattr(exc, "orig", None) + if orig is None: + return constraint_name in str(exc) + diag = getattr(orig, "__cause__", None) or orig + pg_constraint = getattr(diag, "constraint_name", None) + if pg_constraint == constraint_name: + return True + return constraint_name in str(exc) diff --git a/backend/src/pequi/repositories/journey_event_repo.py b/backend/src/pequi/repositories/journey_event_repo.py new file mode 100644 index 0000000..4324013 --- /dev/null +++ b/backend/src/pequi/repositories/journey_event_repo.py @@ -0,0 +1,89 @@ +import uuid +from uuid import UUID + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.dose_log import DoseLog +from pequi.models.journey_event import JourneyEvent + + +class JourneyEventRepository: + """Persistência de eventos emitidos por fluxos clínicos e workers.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, event: JourneyEvent) -> JourneyEvent: + self._session.add(event) + await self._session.flush() + await self._session.refresh(event) + return event + + async def create_for_dose(self, patient_id: UUID, dose: DoseLog) -> JourneyEvent: + taken = dose.taken_at is not None and not dose.skipped + title = "Dose registrada" + if dose.skipped: + description = f"{dose.drug_name} marcada como não tomada." + display_type = "dose_skipped" + elif taken: + description = f"{dose.drug_name} registrada como tomada." + display_type = "dose_taken" + else: + description = f"{dose.drug_name} registrada como pendente." + display_type = "dose_pending" + + return await self.create( + JourneyEvent( + id=uuid.uuid4(), + patient_id=patient_id, + treatment_id=dose.treatment_id, + event_type="dose_registered", + title=title, + description=description, + occurred_at=dose.taken_at or dose.expected_at, + event_metadata={ + "drug_name": dose.drug_name, + "display_type": display_type, + "skipped": dose.skipped, + }, + source_type="dose_log", + source_id=dose.id, + ) + ) + + async def list_by_patient(self, patient_id: UUID) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where(JourneyEvent.patient_id == patient_id) + .order_by(JourneyEvent.occurred_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def list_for_treatment( + self, + patient_id: UUID, + treatment_id: UUID, + ) -> list[JourneyEvent]: + stmt = ( + select(JourneyEvent) + .where( + JourneyEvent.patient_id == patient_id, + or_( + JourneyEvent.treatment_id == treatment_id, + JourneyEvent.treatment_id.is_(None), + ), + ) + .order_by(JourneyEvent.occurred_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_source(self, source_type: str, source_id: UUID) -> JourneyEvent | None: + stmt = select(JourneyEvent).where( + JourneyEvent.source_type == source_type, + JourneyEvent.source_id == source_id, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/src/pequi/repositories/treatment_repo.py b/backend/src/pequi/repositories/treatment_repo.py index bcbedc5..b19b172 100644 --- a/backend/src/pequi/repositories/treatment_repo.py +++ b/backend/src/pequi/repositories/treatment_repo.py @@ -1,22 +1,32 @@ from uuid import UUID from sqlalchemy import desc, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from pequi.core.exceptions import ConflictError from pequi.models.dose_log import AdherenceSnapshot from pequi.models.symptom import Symptom from pequi.models.treatment import Treatment, TreatmentStatus +_ACTIVE_TREATMENT_CONSTRAINT = "uq_treatments_one_active_per_patient" + class TreatmentRepository: def __init__(self, session: AsyncSession) -> None: self._session = session async def create(self, treatment: Treatment) -> Treatment: - self._session.add(treatment) - await self._session.flush() - await self._session.refresh(treatment) - return treatment + try: + async with self._session.begin_nested(): + self._session.add(treatment) + await self._session.flush() + await self._session.refresh(treatment) + return treatment + except IntegrityError as exc: + if _constraint_violated(exc, _ACTIVE_TREATMENT_CONSTRAINT): + raise ConflictError("Paciente já possui um tratamento ativo.") from exc + raise async def get_by_id(self, treatment_id: UUID) -> Treatment | None: """Retorna tratamento ativo (não soft-deleted).""" @@ -42,9 +52,13 @@ async def list_by_patient_id( *, status: TreatmentStatus | None = None, ) -> list[Treatment]: - stmt = select(Treatment).where( - Treatment.patient_id == patient_id, - Treatment.deleted_at.is_(None), + stmt = ( + select(Treatment) + .where( + Treatment.patient_id == patient_id, + Treatment.deleted_at.is_(None), + ) + .order_by(desc(Treatment.created_at)) ) if status is not None: stmt = stmt.where(Treatment.status == status) @@ -78,3 +92,14 @@ async def get_by_ids(self, symptom_ids: list[UUID]) -> list[Symptom]: stmt = select(Symptom).where(Symptom.id.in_(symptom_ids)) result = await self._session.execute(stmt) return list(result.scalars().all()) + + +def _constraint_violated(exc: IntegrityError, constraint_name: str) -> bool: + orig = getattr(exc, "orig", None) + if orig is None: + return constraint_name in str(exc) + diag = getattr(orig, "__cause__", None) or orig + pg_constraint = getattr(diag, "constraint_name", None) + if pg_constraint == constraint_name: + return True + return constraint_name in str(exc) diff --git a/backend/src/pequi/routers/journey.py b/backend/src/pequi/routers/journey.py new file mode 100644 index 0000000..1d596f3 --- /dev/null +++ b/backend/src/pequi/routers/journey.py @@ -0,0 +1,33 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import get_current_patient, get_db +from pequi.core.rate_limit import limiter +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase + +router = APIRouter() + + +@router.get("", response_model=JourneyResponse) +@limiter.limit("100/minute") +async def get_journey( + request: Request, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> JourneyResponse: + use_case = GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + DoseRepository(session), + HealthAppointmentRepository(session), + JourneyEventRepository(session), + ) + return await use_case.execute(patient_user_id) diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index d8afa0b..adc761d 100644 --- a/backend/src/pequi/routers/patient.py +++ b/backend/src/pequi/routers/patient.py @@ -7,7 +7,7 @@ from pequi.core.rate_limit import limiter from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -49,15 +49,10 @@ def _treatment_repos( session: AsyncSession, -) -> tuple[ - PatientRepository, - TreatmentRepository, - HealthProfessionalRepository, -]: +) -> tuple[PatientRepository, TreatmentRepository]: return ( PatientRepository(session), TreatmentRepository(session), - HealthProfessionalRepository(session), ) @@ -116,7 +111,7 @@ async def get_my_treatment_record( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> PatientTreatmentRecordRead: - patient_repo, _, _ = _treatment_repos(session) + patient_repo, _ = _treatment_repos(session) use_case = GetPatientTreatmentRecordUseCase(patient_repo) return await use_case.execute(user_id) @@ -129,11 +124,10 @@ async def save_my_treatment_record( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> PatientTreatmentRecordRead: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) return await use_case.execute(user_id, body) @@ -145,7 +139,7 @@ async def get_my_active_treatment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> TreatmentResponse | None: - patient_repo, treatment_repo, _ = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = GetPatientActiveTreatmentUseCase(patient_repo, treatment_repo) return await use_case.execute(user_id) @@ -157,7 +151,7 @@ async def get_my_medication_checklist( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> MedicationChecklistResponse: - patient_repo, treatment_repo, _ = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) use_case = GetMedicationChecklistUseCase(patient_repo, treatment_repo) return await use_case.execute(user_id) @@ -187,15 +181,15 @@ async def create_my_appointment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> HealthAppointmentResponse: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) appointment_repo = HealthAppointmentRepository(session) dose_repo = DoseRepository(session) use_case = CreatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + JourneyEventRepository(session), ) return await use_case.execute(user_id, body) @@ -212,14 +206,14 @@ async def update_my_appointment( user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> HealthAppointmentResponse: - patient_repo, treatment_repo, professional_repo = _treatment_repos(session) + patient_repo, treatment_repo = _treatment_repos(session) appointment_repo = HealthAppointmentRepository(session) dose_repo = DoseRepository(session) use_case = UpdatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) diff --git a/backend/src/pequi/routers/treatment.py b/backend/src/pequi/routers/treatment.py index 7b01fe7..3d4edb4 100644 --- a/backend/src/pequi/routers/treatment.py +++ b/backend/src/pequi/routers/treatment.py @@ -12,20 +12,21 @@ from pequi.core.rate_limit import limiter from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import SymptomRepository, TreatmentRepository -from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse -from pequi.schemas.treatment import ( - AdherenceSnapshotResponse, - SymptomResponse, - TreatmentCreate, - TreatmentResponse, +from pequi.schemas.treatment import SymptomResponse +from pequi.schemas.v1.dose_log import DoseLogCreateV1, DoseLogResponseV1 +from pequi.schemas.v1.treatment import ( + AdherenceSnapshotResponseV1, + TreatmentCreateV1, + TreatmentResponseV1, ) -from pequi.use_cases.create_treatment import CreateTreatmentUseCase -from pequi.use_cases.get_adherence import GetAdherenceUseCase -from pequi.use_cases.get_treatment import GetTreatmentUseCase from pequi.use_cases.list_symptoms import ListSymptomsUseCase -from pequi.use_cases.register_dose import RegisterDoseUseCase +from pequi.use_cases.v1.create_treatment import CreateTreatmentV1UseCase +from pequi.use_cases.v1.get_adherence import GetAdherenceV1UseCase +from pequi.use_cases.v1.get_treatment import GetTreatmentV1UseCase +from pequi.use_cases.v1.register_dose import RegisterDoseV1UseCase router = APIRouter() symptoms_router = APIRouter() @@ -49,91 +50,68 @@ def _make_repos( ) -# --------------------------------------------------------------------------- -# POST /v1/treatments — apenas profissionais -# --------------------------------------------------------------------------- - - -@router.post("", response_model=TreatmentResponse, status_code=201) +@router.post("", response_model=TreatmentResponseV1, status_code=201) @limiter.limit("10/minute") async def create_treatment( request: Request, - body: TreatmentCreate, + body: TreatmentCreateV1, professional_user_id: UUID = Depends(get_current_professional), session: AsyncSession = Depends(get_db), -) -> TreatmentResponse: +) -> TreatmentResponseV1: treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = CreateTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + use_case = CreateTreatmentV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(professional_user_id, body) -# --------------------------------------------------------------------------- -# GET /v1/treatments/{id} — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.get("/{treatment_id}", response_model=TreatmentResponse) +@router.get("/{treatment_id}", response_model=TreatmentResponseV1) @limiter.limit("100/minute") async def get_treatment( request: Request, treatment_id: UUID, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> TreatmentResponse: +) -> TreatmentResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = GetTreatmentUseCase(treatment_repo, patient_repo, professional_repo) + use_case = GetTreatmentV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(actor_user_id, actor_role, treatment_id) -# --------------------------------------------------------------------------- -# POST /v1/treatments/{id}/doses — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.post("/{treatment_id}/doses", response_model=DoseLogResponse, status_code=201) +@router.post("/{treatment_id}/doses", response_model=DoseLogResponseV1, status_code=201) @limiter.limit("20/minute") async def register_dose( request: Request, treatment_id: UUID, - body: DoseLogCreate, + body: DoseLogCreateV1, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> DoseLogResponse: +) -> DoseLogResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, dose_repo, _ = _make_repos(session) - use_case = RegisterDoseUseCase(treatment_repo, dose_repo, patient_repo, professional_repo) + use_case = RegisterDoseV1UseCase( + treatment_repo, + dose_repo, + patient_repo, + professional_repo, + JourneyEventRepository(session), + ) return await use_case.execute(actor_user_id, actor_role, treatment_id, body) -# --------------------------------------------------------------------------- -# GET /v1/treatments/{id}/adherence — paciente ou profissional -# --------------------------------------------------------------------------- - - -@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponse) +@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponseV1) @limiter.limit("100/minute") async def get_adherence( request: Request, treatment_id: UUID, actor: tuple[UUID, str] = Depends(get_actor_from_token), session: AsyncSession = Depends(get_db), -) -> AdherenceSnapshotResponse: +) -> AdherenceSnapshotResponseV1: actor_user_id, actor_role = actor - treatment_repo, patient_repo, professional_repo, _, _ = _make_repos(session) - use_case = GetAdherenceUseCase(treatment_repo, patient_repo, professional_repo) + use_case = GetAdherenceV1UseCase(treatment_repo, patient_repo, professional_repo) return await use_case.execute(actor_user_id, actor_role, treatment_id) -# --------------------------------------------------------------------------- -# GET /v1/symptoms — qualquer usuário autenticado -# Registrado em main.py como prefix="/v1/symptoms" -# --------------------------------------------------------------------------- - - @symptoms_router.get("", response_model=list[SymptomResponse]) @limiter.limit("50/minute") async def list_symptoms( diff --git a/backend/src/pequi/routers/treatment_v2.py b/backend/src/pequi/routers/treatment_v2.py new file mode 100644 index 0000000..ade9599 --- /dev/null +++ b/backend/src/pequi/routers/treatment_v2.py @@ -0,0 +1,91 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.dependencies import get_current_patient, get_db +from pequi.core.rate_limit import limiter +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse +from pequi.schemas.treatment import ( + AdherenceSnapshotResponse, + TreatmentCreate, + TreatmentResponse, +) +from pequi.use_cases.create_treatment import CreateTreatmentUseCase +from pequi.use_cases.get_adherence import GetAdherenceUseCase +from pequi.use_cases.get_treatment import GetTreatmentUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + +router = APIRouter() + + +def _make_repos( + session: AsyncSession, +) -> tuple[TreatmentRepository, PatientRepository, DoseRepository]: + return ( + TreatmentRepository(session), + PatientRepository(session), + DoseRepository(session), + ) + + +@router.post("", response_model=TreatmentResponse, status_code=201) +@limiter.limit("10/minute") +async def create_treatment( + request: Request, + body: TreatmentCreate, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = CreateTreatmentUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, body) + + +@router.get("/{treatment_id}", response_model=TreatmentResponse) +@limiter.limit("100/minute") +async def get_treatment( + request: Request, + treatment_id: UUID, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> TreatmentResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = GetTreatmentUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, treatment_id) + + +@router.post("/{treatment_id}/doses", response_model=DoseLogResponse, status_code=201) +@limiter.limit("20/minute") +async def register_dose( + request: Request, + treatment_id: UUID, + body: DoseLogCreate, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DoseLogResponse: + treatment_repo, patient_repo, dose_repo = _make_repos(session) + use_case = RegisterDoseUseCase( + treatment_repo, + dose_repo, + patient_repo, + JourneyEventRepository(session), + ) + return await use_case.execute(patient_user_id, treatment_id, body) + + +@router.get("/{treatment_id}/adherence", response_model=AdherenceSnapshotResponse) +@limiter.limit("100/minute") +async def get_adherence( + request: Request, + treatment_id: UUID, + patient_user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> AdherenceSnapshotResponse: + treatment_repo, patient_repo, _ = _make_repos(session) + use_case = GetAdherenceUseCase(treatment_repo, patient_repo) + return await use_case.execute(patient_user_id, treatment_id) diff --git a/backend/src/pequi/schemas/dose_log.py b/backend/src/pequi/schemas/dose_log.py index 68848f1..1858656 100644 --- a/backend/src/pequi/schemas/dose_log.py +++ b/backend/src/pequi/schemas/dose_log.py @@ -5,11 +5,7 @@ class DoseLogCreate(BaseModel): - """Payload para registrar uma dose (tomada, pulada ou supervisionada). - - Validações de permissão (paciente vs. profissional, supervisionada vs. diária) - são realizadas no use case, não aqui. - """ + """Payload para o paciente registrar uma dose (tomada ou pulada).""" model_config = ConfigDict(extra="forbid") @@ -18,22 +14,11 @@ class DoseLogCreate(BaseModel): taken_at: datetime | None = None skipped: bool = False skip_reason: str | None = Field(default=None, max_length=500) - supervised: bool = False - via_consultation: bool = Field( - default=False, - description=( - "Quando true, paciente pode registrar dose supervisionada " - "apenas após consulta na unidade (autodeclaração no app)." - ), - ) @model_validator(mode="after") def validate_skip_and_taken(self) -> "DoseLogCreate": if self.skipped and self.taken_at is not None: raise ValueError("Uma dose não pode ser simultaneamente tomada e pulada.") - if self.skipped is False and self.taken_at is None and not self.supervised: - # Permite dose "pendente" (nem tomada nem pulada) apenas se não for o caso base - pass return self @@ -45,8 +30,6 @@ class DoseLogResponse(BaseModel): taken_at: datetime | None = None skipped: bool skip_reason: str | None = None - supervised: bool - registered_by: UUID | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/journey.py b/backend/src/pequi/schemas/journey.py new file mode 100644 index 0000000..9691463 --- /dev/null +++ b/backend/src/pequi/schemas/journey.py @@ -0,0 +1,71 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class JourneyEventResponse(BaseModel): + """Evento unificado da timeline.""" + + type: str + event_type: str + date: datetime | date + occurred_at: datetime | date + title: str + description: str + metadata: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMonthResponse(BaseModel): + month: int + month_number: int + is_current: bool + events: list[JourneyEventResponse] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class JourneySummaryBlock(BaseModel): + completed_doses: int + pending_doses: int + skipped_doses: int = 0 + adherence_pct: Decimal | None = None + total_months: int + current_month: int + progress_pct: Decimal + total_consultations: int + total_doses_registered: int + + model_config = ConfigDict(from_attributes=True) + + +class JourneyPatientBlock(BaseModel): + id: UUID + classification: str | None = None + + +class JourneyTreatmentBlock(BaseModel): + id: UUID + regimen: str + start_date: date + expected_end: date + status: str + + +class JourneyResponse(BaseModel): + patient: JourneyPatientBlock + treatment: JourneyTreatmentBlock + patient_id: UUID + regimen: str + start_date: date + expected_end: date + current_month: int + progress_pct: Decimal + months: list[JourneyMonthResponse] + summary: JourneySummaryBlock + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/treatment.py b/backend/src/pequi/schemas/treatment.py index c3d9233..52cc64e 100644 --- a/backend/src/pequi/schemas/treatment.py +++ b/backend/src/pequi/schemas/treatment.py @@ -6,7 +6,7 @@ class TreatmentCreate(BaseModel): - """Payload para criar um novo tratamento MDT. + """Payload para o paciente criar seu próprio tratamento MDT. ``expected_end`` é calculado automaticamente pelo use case: PB = start_date + 6 meses, MB = start_date + 12 meses. @@ -14,7 +14,6 @@ class TreatmentCreate(BaseModel): model_config = ConfigDict(extra="forbid") - patient_id: UUID regimen: str = Field( ..., pattern="^(PB|MB)$", @@ -27,7 +26,6 @@ class TreatmentCreate(BaseModel): class TreatmentResponse(BaseModel): id: UUID patient_id: UUID - prescribed_by: UUID regimen: str start_date: date expected_end: date diff --git a/backend/src/pequi/schemas/v1/__init__.py b/backend/src/pequi/schemas/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/schemas/v1/dose_log.py b/backend/src/pequi/schemas/v1/dose_log.py new file mode 100644 index 0000000..d813bbe --- /dev/null +++ b/backend/src/pequi/schemas/v1/dose_log.py @@ -0,0 +1,39 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DoseLogCreateV1(BaseModel): + """Contrato legado v1 — paciente ou profissional registra dose.""" + + model_config = ConfigDict(extra="forbid") + + drug_name: str = Field(..., min_length=1, max_length=200) + expected_at: datetime + taken_at: datetime | None = None + skipped: bool = False + skip_reason: str | None = Field(default=None, max_length=500) + supervised: bool = False + via_consultation: bool = False + + @model_validator(mode="after") + def validate_skip_and_taken(self) -> "DoseLogCreateV1": + if self.skipped and self.taken_at is not None: + raise ValueError("Uma dose não pode ser simultaneamente tomada e pulada.") + return self + + +class DoseLogResponseV1(BaseModel): + id: UUID + treatment_id: UUID + drug_name: str + expected_at: datetime + taken_at: datetime | None = None + skipped: bool + skip_reason: str | None = None + supervised: bool + registered_by: UUID | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/v1/treatment.py b/backend/src/pequi/schemas/v1/treatment.py new file mode 100644 index 0000000..21a6bf5 --- /dev/null +++ b/backend/src/pequi/schemas/v1/treatment.py @@ -0,0 +1,45 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class TreatmentCreateV1(BaseModel): + """Contrato legado v1 — profissional cria tratamento para um paciente.""" + + model_config = ConfigDict(extra="forbid") + + patient_id: UUID + regimen: str = Field(..., pattern="^(PB|MB)$") + start_date: date + notes: str | None = Field(default=None, max_length=2000) + + +class TreatmentResponseV1(BaseModel): + id: UUID + patient_id: UUID + prescribed_by: UUID + regimen: str + start_date: date + expected_end: date + status: str + notes: str | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AdherenceSnapshotResponseV1(BaseModel): + id: UUID + patient_id: UUID + treatment_id: UUID + period_start: date + period_end: date + total_doses: int + taken_doses: int + adherence_pct: Decimal + calculated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/services/appointment_consultation_effects.py b/backend/src/pequi/services/appointment_consultation_effects.py index 2e75da8..6f6aef0 100644 --- a/backend/src/pequi/services/appointment_consultation_effects.py +++ b/backend/src/pequi/services/appointment_consultation_effects.py @@ -1,11 +1,12 @@ -"""Efeitos colaterais ao concluir uma consulta (tratamento + doses supervisionadas).""" +"""Efeitos colaterais ao concluir uma consulta (tratamento + doses).""" from datetime import UTC, date, datetime, time from uuid import UUID +from pequi.core.exceptions import ConflictError from pequi.models.treatment import TreatmentStatus from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate @@ -24,21 +25,20 @@ def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo self._save_treatment = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) self._register_dose = RegisterDoseUseCase( treatment_repo, dose_repo, patient_repo, - professional_repo, + journey_event_repo, ) async def apply_on_first_completion( @@ -122,17 +122,14 @@ async def _register_supervised_doses( for drug_name in drug_names: try: await self._register_dose.execute( - actor_user_id=user_id, - actor_role="patient", + patient_user_id=user_id, treatment_id=treatment.id, data=DoseLogCreate( drug_name=drug_name, expected_at=expected_at, taken_at=taken_at, skipped=False, - supervised=True, - via_consultation=True, ), ) - except Exception: + except ConflictError: continue diff --git a/backend/src/pequi/services/journey_service.py b/backend/src/pequi/services/journey_service.py new file mode 100644 index 0000000..c29a851 --- /dev/null +++ b/backend/src/pequi/services/journey_service.py @@ -0,0 +1,266 @@ +"""Montagem da jornada de tratamento do paciente.""" + +from __future__ import annotations + +import calendar +from datetime import UTC, date, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from typing import TYPE_CHECKING + +from pequi.models.treatment import TreatmentRegimen +from pequi.schemas.journey import ( + JourneyEventResponse, + JourneyMonthResponse, + JourneyResponse, + JourneySummaryBlock, +) + +if TYPE_CHECKING: + from pequi.models.dose_log import AdherenceSnapshot, DoseLog + from pequi.models.health_appointment import PatientHealthAppointment + from pequi.models.journey_event import JourneyEvent + from pequi.models.patient import PatientProfile + from pequi.models.treatment import Treatment + +_REGIMEN_MONTHS = { + TreatmentRegimen.PB: 6, + TreatmentRegimen.MB: 12, +} + + +class JourneyService: + """Cálculo stateless de progresso, agrupamento mensal e timeline.""" + + @classmethod + def build_journey( + cls, + *, + patient_id, + treatment: Treatment, + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + adherence_snapshot: AdherenceSnapshot | None, + patient: PatientProfile | None = None, + journey_events: list[JourneyEvent] | None = None, + today: date | None = None, + ) -> JourneyResponse: + today = today or datetime.now(UTC).date() + regimen = ( + treatment.regimen.value + if hasattr(treatment.regimen, "value") + else str(treatment.regimen) + ) + total_months = _REGIMEN_MONTHS.get(TreatmentRegimen(regimen), 6) + current_month = cls.calculate_current_month(treatment.start_date, today, total_months) + progress_pct = cls.calculate_progress_pct( + treatment.start_date, + treatment.expected_end, + today, + ) + months = cls.build_months( + start_date=treatment.start_date, + total_months=total_months, + current_month=current_month, + doses=doses, + appointments=appointments, + journey_events=journey_events or [], + ) + summary = cls.build_summary( + doses, + appointments, + adherence_snapshot, + total_months=total_months, + current_month=current_month, + progress_pct=progress_pct, + ) + status = ( + treatment.status.value if hasattr(treatment.status, "value") else str(treatment.status) + ) + + return JourneyResponse( + patient={ + "id": patient_id, + "classification": getattr(patient, "classification", None), + }, + treatment={ + "id": treatment.id, + "regimen": regimen, + "start_date": treatment.start_date, + "expected_end": treatment.expected_end, + "status": status, + }, + patient_id=patient_id, + regimen=regimen, + start_date=treatment.start_date, + expected_end=treatment.expected_end, + current_month=current_month, + progress_pct=progress_pct, + months=months, + summary=summary, + ) + + @staticmethod + def calculate_current_month(start_date: date, today: date, total_months: int) -> int: + if today < start_date: + return 1 + months_elapsed = (today.year - start_date.year) * 12 + (today.month - start_date.month) + if today.day < start_date.day: + months_elapsed -= 1 + return min(total_months, max(1, months_elapsed + 1)) + + @staticmethod + def calculate_progress_pct(start_date: date, expected_end: date, today: date) -> Decimal: + total_days = (expected_end - start_date).days + if total_days <= 0: + return Decimal("0.0") + elapsed = max(0, min((today - start_date).days, total_days)) + pct = Decimal(elapsed) / Decimal(total_days) * Decimal("100") + return pct.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + + @classmethod + def build_months( + cls, + *, + start_date: date, + total_months: int, + current_month: int, + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + journey_events: list[JourneyEvent], + ) -> list[JourneyMonthResponse]: + persisted_dose_ids = { + event.source_id for event in journey_events if event.source_type == "dose_log" + } + months: list[JourneyMonthResponse] = [] + for month_index in range(1, total_months + 1): + month_start = _add_months(start_date, month_index - 1) + month_end = _add_months(start_date, month_index) - timedelta(days=1) + month_doses = [ + dose + for dose in doses + if dose.id not in persisted_dose_ids + and month_start <= dose.expected_at.date() <= month_end + ] + month_appointments = [ + item + for item in appointments + if item.performed and month_start <= item.appointment_date <= month_end + ] + persisted = [ + event + for event in journey_events + if month_start <= event.occurred_at.date() <= month_end + ] + events = cls._build_dose_events(month_doses) + events.extend(cls._build_consultation_events(month_appointments)) + events.extend(cls._build_persisted_events(persisted)) + events.sort(key=_event_sort_key) + months.append( + JourneyMonthResponse( + month=month_index, + month_number=month_index, + is_current=month_index == current_month, + events=events, + ) + ) + return list(reversed(months)) + + @staticmethod + def build_summary( + doses: list[DoseLog], + appointments: list[PatientHealthAppointment], + adherence_snapshot: AdherenceSnapshot | None, + *, + total_months: int, + current_month: int, + progress_pct: Decimal, + ) -> JourneySummaryBlock: + completed = sum(1 for dose in doses if dose.taken_at is not None and not dose.skipped) + pending = sum(1 for dose in doses if dose.taken_at is None and not dose.skipped) + skipped = sum(1 for dose in doses if dose.skipped) + adherence_pct = ( + Decimal(str(adherence_snapshot.adherence_pct)) + if adherence_snapshot is not None + else None + ) + return JourneySummaryBlock( + completed_doses=completed, + pending_doses=pending, + skipped_doses=skipped, + adherence_pct=adherence_pct, + total_months=total_months, + current_month=current_month, + progress_pct=progress_pct, + total_consultations=sum(1 for item in appointments if item.performed), + total_doses_registered=len(doses), + ) + + @staticmethod + def _build_dose_events(doses: list[DoseLog]) -> list[JourneyEventResponse]: + events: list[JourneyEventResponse] = [] + for dose in doses: + occurred_at = dose.taken_at or dose.expected_at + display_type = "dose_skipped" if dose.skipped else "dose_taken" + events.append( + JourneyEventResponse( + type=display_type, + event_type="dose_registered", + date=occurred_at, + occurred_at=occurred_at, + title="Dose pulada" if dose.skipped else "Dose tomada", + description=f"{dose.drug_name} registrada na jornada.", + metadata={"drug_name": dose.drug_name, "skipped": dose.skipped}, + ) + ) + return events + + @staticmethod + def _build_consultation_events( + appointments: list[PatientHealthAppointment], + ) -> list[JourneyEventResponse]: + events: list[JourneyEventResponse] = [] + for appointment in appointments: + description = f"{appointment.appointment_type} em {appointment.location}" + if appointment.professional: + description += f" com {appointment.professional}" + events.append( + JourneyEventResponse( + type="consultation_registered", + event_type="consultation", + date=appointment.appointment_date, + occurred_at=appointment.appointment_date, + title="Consulta registrada", + description=description, + ) + ) + return events + + @staticmethod + def _build_persisted_events(events: list[JourneyEvent]) -> list[JourneyEventResponse]: + return [ + JourneyEventResponse( + type=event.event_metadata.get("display_type", event.event_type), + event_type=event.event_type, + date=event.occurred_at, + occurred_at=event.occurred_at, + title=event.title, + description=event.description, + metadata=event.event_metadata, + ) + for event in events + ] + + +def _event_sort_key(event: JourneyEventResponse) -> datetime: + value = event.occurred_at + if isinstance(value, datetime): + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return datetime.combine(value, datetime.min.time(), tzinfo=UTC) + + +def _add_months(start_date: date, months: int) -> date: + total_months = start_date.month - 1 + months + year = start_date.year + total_months // 12 + month = total_months % 12 + 1 + day = min(start_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) diff --git a/backend/src/pequi/use_cases/create_treatment.py b/backend/src/pequi/use_cases/create_treatment.py index 04b135c..2acf573 100644 --- a/backend/src/pequi/use_cases/create_treatment.py +++ b/backend/src/pequi/use_cases/create_treatment.py @@ -1,77 +1,52 @@ -import calendar import uuid -from datetime import date -from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.exceptions import ConflictError, NotFoundError, ValidationFailedError from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import TreatmentCreate, TreatmentResponse - -_REGIMEN_MONTHS = { - TreatmentRegimen.PB: 6, - TreatmentRegimen.MB: 12, -} +from pequi.use_cases.treatment_schedule import calculate_expected_end class CreateTreatmentUseCase: - """Cria um tratamento MDT para um paciente. - - Apenas profissionais de saúde podem criar tratamentos, e somente para - pacientes da mesma unidade de saúde. - """ + """v2 — paciente autenticado cria seu próprio tratamento MDT.""" def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - professional_user_id: uuid.UUID, + patient_user_id: uuid.UUID, data: TreatmentCreate, ) -> TreatmentResponse: - professional = await self._professional_repo.get_by_user_id(professional_user_id) - if professional is None: - raise NotFoundError("HealthProfessional", str(professional_user_id)) - - patient = await self._patient_repo.get_by_id(data.patient_id) + patient = await self._patient_repo.get_by_user_id(patient_user_id) if patient is None: - raise NotFoundError("PatientProfile", str(data.patient_id)) + raise NotFoundError("PatientProfile", str(patient_user_id)) - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a pacientes de outra unidade de saúde." - ) + existing = await self._treatment_repo.get_active_by_patient_id(patient.id) + if existing is not None: + raise ValidationFailedError("Paciente já possui um tratamento ativo.") regimen = TreatmentRegimen(data.regimen) - expected_end = _calculate_expected_end(data.start_date, regimen) + expected_end = calculate_expected_end(data.start_date, regimen) treatment = Treatment( id=uuid.uuid4(), - patient_id=data.patient_id, - prescribed_by=professional.id, + patient_id=patient.id, regimen=regimen, start_date=data.start_date, expected_end=expected_end, status=TreatmentStatus.active, notes=data.notes, ) - treatment = await self._treatment_repo.create(treatment) - return TreatmentResponse.model_validate(treatment) - + try: + treatment = await self._treatment_repo.create(treatment) + except ConflictError: + raise ConflictError("Paciente já possui um tratamento ativo.") from None -def _calculate_expected_end(start_date: date, regimen: TreatmentRegimen) -> date: - """Adiciona N meses à data de início, limitando ao último dia do mês destino.""" - months = _REGIMEN_MONTHS[regimen] - total_months = start_date.month - 1 + months - year = start_date.year + total_months // 12 - month = total_months % 12 + 1 - day = min(start_date.day, calendar.monthrange(year, month)[1]) - return date(year, month, day) + return TreatmentResponse.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/export_account_data.py b/backend/src/pequi/use_cases/export_account_data.py index 785d44c..12956ac 100644 --- a/backend/src/pequi/use_cases/export_account_data.py +++ b/backend/src/pequi/use_cases/export_account_data.py @@ -96,7 +96,6 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict include=[ "id", "patient_id", - "prescribed_by", "regimen", "start_date", "expected_end", @@ -119,8 +118,6 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict "taken_at", "skipped", "skip_reason", - "supervised", - "registered_by", "created_at", ], ) @@ -143,6 +140,25 @@ async def execute(self, user_id: UUID, *, ip_address: str | None = None) -> dict ) for row in await self._repo.list_adherence_snapshots(patient.id) ], + "journey_events": [ + self._dump( + row, + include=[ + "id", + "patient_id", + "treatment_id", + "event_type", + "title", + "description", + "occurred_at", + "source_type", + "source_id", + "created_at", + ], + extra={"metadata": row.event_metadata}, + ) + for row in await self._repo.list_journey_events(patient.id) + ], "alerts": [ self._dump( row, diff --git a/backend/src/pequi/use_cases/get_adherence.py b/backend/src/pequi/use_cases/get_adherence.py index 9324414..8f82b41 100644 --- a/backend/src/pequi/use_cases/get_adherence.py +++ b/backend/src/pequi/use_cases/get_adherence.py @@ -1,14 +1,13 @@ import uuid from pequi.core.exceptions import ForbiddenError, NotFoundError -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import AdherenceSnapshotResponse class GetAdherenceUseCase: - """Retorna o snapshot de adesão mais recente para um tratamento. + """Retorna o snapshot de adesão mais recente — somente o paciente dono. Nunca recalcula — lê exclusivamente de ``adherence_snapshots``. Retorna NotFoundError se nenhum snapshot foi calculado ainda pelo worker. @@ -18,29 +17,22 @@ def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, ) -> AdherenceSnapshotResponse: treatment = await self._treatment_repo.get_by_id(treatment_id) if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - await _assert_access( - actor_user_id=actor_user_id, - actor_role=actor_role, - treatment=treatment, - patient_repo=self._patient_repo, - professional_repo=self._professional_repo, - ) + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment_id) if snapshot is None: @@ -50,34 +42,3 @@ async def execute( ) return AdherenceSnapshotResponse.model_validate(snapshot) - - -async def _assert_access( - *, - actor_user_id: uuid.UUID, - actor_role: str, - treatment, - patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, -) -> None: - if actor_role == "patient": - patient = await patient_repo.get_by_user_id(actor_user_id) - if patient is None or patient.id != treatment.patient_id: - raise ForbiddenError("Paciente não tem acesso a este tratamento.") - - elif actor_role == "health_professional": - professional = await professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await patient_repo.get_by_id(treatment.patient_id) - if patient is None: - raise NotFoundError("PatientProfile", str(treatment.patient_id)) - - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - else: - raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/get_patient_journey.py b/backend/src/pequi/use_cases/get_patient_journey.py new file mode 100644 index 0000000..47fb475 --- /dev/null +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.services.journey_service import JourneyService + + +class GetPatientJourneyUseCase: + """Retorna a jornada de tratamento do paciente autenticado.""" + + def __init__( + self, + patient_repo: PatientRepository, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + appointment_repo: HealthAppointmentRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._patient_repo = patient_repo + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._appointment_repo = appointment_repo + self._journey_event_repo = journey_event_repo + + async def execute(self, user_id: UUID) -> JourneyResponse: + patient = await self._patient_repo.get_or_create_by_user_id(user_id) + treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) + if treatment is None: + raise NotFoundError("Treatment", "Nenhum tratamento ativo encontrado.") + + doses = await self._dose_repo.list_by_treatment(treatment.id) + appointments = await self._appointment_repo.list_by_patient_id(patient.id) + snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) + journey_events = await self._journey_event_repo.list_for_treatment(patient.id, treatment.id) + + return JourneyService.build_journey( + patient_id=patient.id, + patient=patient, + treatment=treatment, + doses=doses, + appointments=appointments, + journey_events=journey_events, + adherence_snapshot=snapshot, + ) diff --git a/backend/src/pequi/use_cases/get_treatment.py b/backend/src/pequi/use_cases/get_treatment.py index 2019491..448388b 100644 --- a/backend/src/pequi/use_cases/get_treatment.py +++ b/backend/src/pequi/use_cases/get_treatment.py @@ -1,77 +1,33 @@ import uuid from pequi.core.exceptions import ForbiddenError, NotFoundError -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.treatment import TreatmentResponse class GetTreatmentUseCase: - """Retorna um tratamento verificando permissões de acesso. - - - Paciente: só acessa o próprio tratamento. - - Profissional: só acessa tratamentos de pacientes da mesma unidade. - """ + """Retorna um tratamento — somente o paciente dono pode acessá-lo.""" def __init__( self, treatment_repo: TreatmentRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._treatment_repo = treatment_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, ) -> TreatmentResponse: treatment = await self._treatment_repo.get_by_id(treatment_id) if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - await _assert_access( - actor_user_id=actor_user_id, - actor_role=actor_role, - treatment=treatment, - patient_repo=self._patient_repo, - professional_repo=self._professional_repo, - ) - - return TreatmentResponse.model_validate(treatment) - - -async def _assert_access( - *, - actor_user_id: uuid.UUID, - actor_role: str, - treatment, - patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, -) -> None: - """Verifica se o ator tem permissão para acessar o tratamento.""" - if actor_role == "patient": - patient = await patient_repo.get_by_user_id(actor_user_id) + patient = await self._patient_repo.get_by_user_id(patient_user_id) if patient is None or patient.id != treatment.patient_id: raise ForbiddenError("Paciente não tem acesso a este tratamento.") - elif actor_role == "health_professional": - professional = await professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await patient_repo.get_by_id(treatment.patient_id) - if patient is None: - raise NotFoundError("PatientProfile", str(treatment.patient_id)) - - if patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - else: - raise ForbiddenError("Acesso negado.") + return TreatmentResponse.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/patient_health_appointment.py b/backend/src/pequi/use_cases/patient_health_appointment.py index e4c085a..7bd3322 100644 --- a/backend/src/pequi/use_cases/patient_health_appointment.py +++ b/backend/src/pequi/use_cases/patient_health_appointment.py @@ -5,7 +5,7 @@ from pequi.models.health_appointment import PatientHealthAppointment from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -35,14 +35,14 @@ async def execute(self, user_id: UUID) -> list[HealthAppointmentResponse]: def _consultation_effects( patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> AppointmentConsultationEffects: return AppointmentConsultationEffects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) @@ -73,16 +73,16 @@ def __init__( patient_repo: PatientRepository, appointment_repo: HealthAppointmentRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._appointment_repo = appointment_repo self._effects = _consultation_effects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) async def execute( @@ -115,16 +115,16 @@ def __init__( patient_repo: PatientRepository, appointment_repo: HealthAppointmentRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, dose_repo: DoseRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._patient_repo = patient_repo self._appointment_repo = appointment_repo self._effects = _consultation_effects( patient_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ) async def execute( diff --git a/backend/src/pequi/use_cases/patient_treatment_record.py b/backend/src/pequi/use_cases/patient_treatment_record.py index 03479d6..819a9b8 100644 --- a/backend/src/pequi/use_cases/patient_treatment_record.py +++ b/backend/src/pequi/use_cases/patient_treatment_record.py @@ -3,7 +3,6 @@ from pequi.core.exceptions import ValidationFailedError from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.patient_treatment import ( @@ -14,7 +13,7 @@ treatment_record_to_storage, ) from pequi.schemas.treatment import TreatmentResponse -from pequi.use_cases.create_treatment import _calculate_expected_end +from pequi.use_cases.treatment_schedule import calculate_expected_end class GetPatientTreatmentRecordUseCase: @@ -36,11 +35,9 @@ def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - professional_repo: HealthProfessionalRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo - self._professional_repo = professional_repo async def execute( self, @@ -82,17 +79,12 @@ async def _ensure_active_mdt(self, patient_id: UUID, data: PatientTreatmentRecor if existing is not None: return - professional = await self._professional_repo.get_first_available() - if professional is None: - return - regimen = TreatmentRegimen(data.classification) - expected_end = _calculate_expected_end(data.treatment_start_date, regimen) + expected_end = calculate_expected_end(data.treatment_start_date, regimen) treatment = Treatment( id=uuid.uuid4(), patient_id=patient_id, - prescribed_by=professional.id, regimen=regimen, start_date=data.treatment_start_date, expected_end=expected_end, diff --git a/backend/src/pequi/use_cases/register_dose.py b/backend/src/pequi/use_cases/register_dose.py index 75d1a56..3346110 100644 --- a/backend/src/pequi/use_cases/register_dose.py +++ b/backend/src/pequi/use_cases/register_dose.py @@ -9,38 +9,30 @@ from pequi.models.dose_log import DoseLog from pequi.models.treatment import TreatmentStatus from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate, DoseLogResponse class RegisterDoseUseCase: - """Registra uma dose (tomada, pulada ou supervisionada). - - Regras de negócio: - - Paciente só pode autoregistrar doses não supervisionadas do próprio tratamento ativo. - - Dose supervisionada deve ser registrada por profissional (registered_by != null). - - Profissional de outra unidade não pode registrar doses no tratamento. - - Duplicidade (treatment_id + drug_name + expected_at) retorna ConflictError → HTTP 409. - """ + """v2 — paciente registra qualquer dose do próprio tratamento ativo.""" def __init__( self, treatment_repo: TreatmentRepository, dose_repo: DoseRepository, patient_repo: PatientRepository, - professional_repo: HealthProfessionalRepository, + journey_event_repo: JourneyEventRepository, ) -> None: self._treatment_repo = treatment_repo self._dose_repo = dose_repo self._patient_repo = patient_repo - self._professional_repo = professional_repo + self._journey_event_repo = journey_event_repo async def execute( self, - actor_user_id: uuid.UUID, - actor_role: str, + patient_user_id: uuid.UUID, treatment_id: uuid.UUID, data: DoseLogCreate, ) -> DoseLogResponse: @@ -48,32 +40,13 @@ async def execute( if treatment is None: raise NotFoundError("Treatment", str(treatment_id)) - registered_by: uuid.UUID | None = None - - if actor_role == "patient": - registered_by = await self._validate_patient_access( - actor_user_id=actor_user_id, - treatment=treatment, - data=data, - ) - elif actor_role == "health_professional": - registered_by = await self._validate_professional_access( - actor_user_id=actor_user_id, - treatment=treatment, - data=data, - ) - else: - raise ForbiddenError("Acesso negado.") + patient = await self._patient_repo.get_by_user_id(patient_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") - duplicate = await self._dose_repo.exists_duplicate( - treatment_id=treatment_id, - drug_name=data.drug_name, - expected_at=data.expected_at, - ) - if duplicate: - raise ConflictError( - f"Dose duplicada: já existe registro para '{data.drug_name}' " - f"em {data.expected_at.isoformat()} neste tratamento." + if treatment.status != TreatmentStatus.active: + raise ValidationFailedError( + "Registro de dose permitido apenas em tratamentos com status 'active'." ) dose_log = DoseLog( @@ -84,53 +57,14 @@ async def execute( taken_at=data.taken_at, skipped=data.skipped, skip_reason=data.skip_reason, - supervised=data.supervised, - registered_by=registered_by, ) - dose_log = await self._dose_repo.create(dose_log) - return DoseLogResponse.model_validate(dose_log) - - async def _validate_patient_access( - self, - actor_user_id: uuid.UUID, - treatment, - data: DoseLogCreate, - ) -> None: - if data.supervised and not data.via_consultation: - raise ForbiddenError( - "Dose supervisionada só pode ser registrada ao informar uma consulta realizada." - ) - - patient = await self._patient_repo.get_by_user_id(actor_user_id) - if patient is None or patient.id != treatment.patient_id: - raise ForbiddenError("Paciente não tem acesso a este tratamento.") - - if treatment.status != TreatmentStatus.active: - raise ValidationFailedError( - "Autoregistro permitido apenas em tratamentos com status 'active'." - ) - - return None - - async def _validate_professional_access( - self, - actor_user_id: uuid.UUID, - treatment, - data: DoseLogCreate, - ) -> uuid.UUID: - professional = await self._professional_repo.get_by_user_id(actor_user_id) - if professional is None: - raise ForbiddenError("Perfil de profissional não encontrado.") - - patient = await self._patient_repo.get_by_id(treatment.patient_id) - if patient is None or patient.health_unit_id != professional.health_unit_id: - raise ForbiddenError( - "Profissional não tem acesso a tratamentos de pacientes de outra unidade." - ) - - if data.supervised and professional is None: - raise ValidationFailedError( - "Dose supervisionada deve ser registrada por um profissional." - ) + try: + dose_log = await self._dose_repo.create(dose_log) + except ConflictError: + raise ConflictError( + f"Dose duplicada: já existe registro para '{data.drug_name}' " + f"em {data.expected_at.isoformat()} neste tratamento." + ) from None + await self._journey_event_repo.create_for_dose(patient.id, dose_log) - return professional.user_id + return DoseLogResponse.model_validate(dose_log) diff --git a/backend/src/pequi/use_cases/treatment_schedule.py b/backend/src/pequi/use_cases/treatment_schedule.py new file mode 100644 index 0000000..7ab319a --- /dev/null +++ b/backend/src/pequi/use_cases/treatment_schedule.py @@ -0,0 +1,19 @@ +import calendar +from datetime import date + +from pequi.models.treatment import TreatmentRegimen + +_REGIMEN_MONTHS = { + TreatmentRegimen.PB: 6, + TreatmentRegimen.MB: 12, +} + + +def calculate_expected_end(start_date: date, regimen: TreatmentRegimen) -> date: + """Adiciona N meses à data de início, limitando ao último dia do mês destino.""" + months = _REGIMEN_MONTHS[regimen] + total_months = start_date.month - 1 + months + year = start_date.year + total_months // 12 + month = total_months % 12 + 1 + day = min(start_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) diff --git a/backend/src/pequi/use_cases/v1/__init__.py b/backend/src/pequi/use_cases/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pequi/use_cases/v1/create_treatment.py b/backend/src/pequi/use_cases/v1/create_treatment.py new file mode 100644 index 0000000..fe0f6f8 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/create_treatment.py @@ -0,0 +1,61 @@ +import uuid + +from pequi.core.exceptions import ConflictError, ForbiddenError, NotFoundError +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import TreatmentCreateV1, TreatmentResponseV1 +from pequi.use_cases.treatment_schedule import calculate_expected_end + + +class CreateTreatmentV1UseCase: + """v1 — profissional cria tratamento para paciente da mesma unidade.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + professional_user_id: uuid.UUID, + data: TreatmentCreateV1, + ) -> TreatmentResponseV1: + professional = await self._professional_repo.get_by_user_id(professional_user_id) + if professional is None: + raise NotFoundError("HealthProfessional", str(professional_user_id)) + + patient = await self._patient_repo.get_by_id(data.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(data.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a pacientes de outra unidade de saúde." + ) + + regimen = TreatmentRegimen(data.regimen) + expected_end = calculate_expected_end(data.start_date, regimen) + + treatment = Treatment( + id=uuid.uuid4(), + patient_id=data.patient_id, + prescribed_by=professional.id, + regimen=regimen, + start_date=data.start_date, + expected_end=expected_end, + status=TreatmentStatus.active, + notes=data.notes, + ) + try: + treatment = await self._treatment_repo.create(treatment) + except ConflictError: + raise ConflictError("Paciente já possui um tratamento ativo.") from None + + return TreatmentResponseV1.model_validate(treatment) diff --git a/backend/src/pequi/use_cases/v1/get_adherence.py b/backend/src/pequi/use_cases/v1/get_adherence.py new file mode 100644 index 0000000..0e53163 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/get_adherence.py @@ -0,0 +1,49 @@ +import uuid + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import AdherenceSnapshotResponseV1 +from pequi.use_cases.v1.get_treatment import _assert_access + + +class GetAdherenceV1UseCase: + """v1 — lê snapshot sem recalcular; paciente ou profissional.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> AdherenceSnapshotResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment_id) + if snapshot is None: + raise NotFoundError( + "AdherenceSnapshot", + "Nenhum snapshot calculado ainda para este tratamento.", + ) + + return AdherenceSnapshotResponseV1.model_validate(snapshot) diff --git a/backend/src/pequi/use_cases/v1/get_treatment.py b/backend/src/pequi/use_cases/v1/get_treatment.py new file mode 100644 index 0000000..05edbc7 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/get_treatment.py @@ -0,0 +1,74 @@ +import uuid + +from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.treatment import TreatmentResponseV1 + + +class GetTreatmentV1UseCase: + """v1 — paciente ou profissional da mesma unidade.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + ) -> TreatmentResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + if treatment.prescribed_by is None: + raise NotFoundError("Treatment", str(treatment_id)) + + await _assert_access( + actor_user_id=actor_user_id, + actor_role=actor_role, + treatment=treatment, + patient_repo=self._patient_repo, + professional_repo=self._professional_repo, + ) + + return TreatmentResponseV1.model_validate(treatment) + + +async def _assert_access( + *, + actor_user_id: uuid.UUID, + actor_role: str, + treatment, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, +) -> None: + if actor_role == "patient": + patient = await patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + elif actor_role == "health_professional": + professional = await professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await patient_repo.get_by_id(treatment.patient_id) + if patient is None: + raise NotFoundError("PatientProfile", str(treatment.patient_id)) + + if patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + else: + raise ForbiddenError("Acesso negado.") diff --git a/backend/src/pequi/use_cases/v1/register_dose.py b/backend/src/pequi/use_cases/v1/register_dose.py new file mode 100644 index 0000000..6dd9897 --- /dev/null +++ b/backend/src/pequi/use_cases/v1/register_dose.py @@ -0,0 +1,127 @@ +import uuid + +from pequi.core.exceptions import ( + ConflictError, + ForbiddenError, + NotFoundError, + ValidationFailedError, +) +from pequi.models.dose_log import DoseLog +from pequi.models.treatment import TreatmentStatus +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.v1.dose_log import DoseLogCreateV1, DoseLogResponseV1 + + +class RegisterDoseV1UseCase: + """v1 — paciente ou profissional; contrato legado com campos supervisionados.""" + + def __init__( + self, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + patient_repo: PatientRepository, + professional_repo: HealthProfessionalRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._patient_repo = patient_repo + self._professional_repo = professional_repo + self._journey_event_repo = journey_event_repo + + async def execute( + self, + actor_user_id: uuid.UUID, + actor_role: str, + treatment_id: uuid.UUID, + data: DoseLogCreateV1, + ) -> DoseLogResponseV1: + treatment = await self._treatment_repo.get_by_id(treatment_id) + if treatment is None: + raise NotFoundError("Treatment", str(treatment_id)) + + registered_by: uuid.UUID | None = None + + if actor_role == "patient": + await self._validate_patient_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + elif actor_role == "health_professional": + registered_by = await self._validate_professional_access( + actor_user_id=actor_user_id, + treatment=treatment, + data=data, + ) + else: + raise ForbiddenError("Acesso negado.") + + dose_log = DoseLog( + id=uuid.uuid4(), + treatment_id=treatment_id, + drug_name=data.drug_name, + expected_at=data.expected_at, + taken_at=data.taken_at, + skipped=data.skipped, + skip_reason=data.skip_reason, + supervised=data.supervised, + registered_by=registered_by, + ) + try: + dose_log = await self._dose_repo.create(dose_log) + except ConflictError: + raise ConflictError( + f"Dose duplicada: já existe registro para '{data.drug_name}' " + f"em {data.expected_at.isoformat()} neste tratamento." + ) from None + await self._journey_event_repo.create_for_dose(treatment.patient_id, dose_log) + + return DoseLogResponseV1.model_validate(dose_log) + + async def _validate_patient_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreateV1, + ) -> None: + if data.supervised and not data.via_consultation: + raise ForbiddenError( + "Dose supervisionada só pode ser registrada ao informar uma consulta realizada." + ) + + patient = await self._patient_repo.get_by_user_id(actor_user_id) + if patient is None or patient.id != treatment.patient_id: + raise ForbiddenError("Paciente não tem acesso a este tratamento.") + + if treatment.status != TreatmentStatus.active: + raise ValidationFailedError( + "Autoregistro permitido apenas em tratamentos com status 'active'." + ) + + async def _validate_professional_access( + self, + actor_user_id: uuid.UUID, + treatment, + data: DoseLogCreateV1, + ) -> uuid.UUID: + professional = await self._professional_repo.get_by_user_id(actor_user_id) + if professional is None: + raise ForbiddenError("Perfil de profissional não encontrado.") + + patient = await self._patient_repo.get_by_id(treatment.patient_id) + if patient is None or patient.health_unit_id != professional.health_unit_id: + raise ForbiddenError( + "Profissional não tem acesso a tratamentos de pacientes de outra unidade." + ) + + if data.supervised and professional is None: + raise ValidationFailedError( + "Dose supervisionada deve ser registrada por um profissional." + ) + + return professional.user_id diff --git a/backend/tests/e2e/test_journey_endpoint.py b/backend/tests/e2e/test_journey_endpoint.py new file mode 100644 index 0000000..0a04573 --- /dev/null +++ b/backend/tests/e2e/test_journey_endpoint.py @@ -0,0 +1,60 @@ +from datetime import date +from uuid import uuid4 + +import pytest + +from pequi.core.auth import create_access_token +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User + + +@pytest.mark.asyncio +async def test_patient_journey_endpoint_matches_frontend_contract( + create_tables, db_session, async_client +): + user = User( + id=uuid4(), + email=f"journey-endpoint-{uuid4()}@test.com", + username=f"journey_{str(uuid4())[:8]}", + hashed_password="$2b$12$placeholder", + full_name="Journey Patient", + role="patient", + ) + unit = HealthUnit(id=uuid4(), name="UBS Journey", city="Cidade", state="SP", cnes="12345678901") + db_session.add_all([user, unit]) + await db_session.flush() + patient = PatientProfile( + id=uuid4(), + user_id=user.id, + health_unit_id=unit.id, + classification="PB", + ) + db_session.add(patient) + await db_session.flush() + db_session.add( + Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 1, 1), + expected_end=date(2026, 7, 1), + status=TreatmentStatus.active, + ) + ) + await db_session.flush() + + token = create_access_token(str(user.id), role="patient") + response = await async_client.get( + "/v1/patients/me/journey", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["patient"]["id"] == str(patient.id) + assert payload["treatment"]["regimen"] == "PB" + assert payload["months"][0]["month_number"] == 6 + assert "is_current" in payload["months"][0] + assert payload["summary"]["total_months"] == 6 diff --git a/backend/tests/e2e/test_patient_treatment_endpoints.py b/backend/tests/e2e/test_patient_treatment_endpoints.py new file mode 100644 index 0000000..00b0b04 --- /dev/null +++ b/backend/tests/e2e/test_patient_treatment_endpoints.py @@ -0,0 +1,105 @@ +"""E2E — endpoints de tratamento no router de pacientes.""" + +from datetime import date + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.auth import hash_password +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User + +pytestmark = pytest.mark.asyncio + + +async def _login_patient(async_client: AsyncClient, *, email: str, password: str) -> str: + response = await async_client.post( + "/v1/auth/login", + json={"identifier": email, "password": password}, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +async def _seed_patient_with_active_treatment( + db_session: AsyncSession, + *, + email: str = "treatment-e2e@example.com", +) -> tuple[User, Treatment]: + user = User( + email=email, + username=email.split("@")[0], + hashed_password=hash_password("patientpassword"), + full_name="Patient E2E", + role="patient", + ) + db_session.add(user) + await db_session.flush() + + unit = HealthUnit(name="UBS E2E", city="Cidade", state="SP", cnes="12345678901") + db_session.add(unit) + await db_session.flush() + + patient = PatientProfile( + user_id=user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + classification="PB", + ) + db_session.add(patient) + await db_session.flush() + + treatment = Treatment( + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2025, 1, 10), + expected_end=date(2025, 7, 10), + status=TreatmentStatus.active, + ) + db_session.add(treatment) + await db_session.flush() + return user, treatment + + +async def test_get_active_treatment_returns_200( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + user, treatment = await _seed_patient_with_active_treatment(db_session) + token = await _login_patient(async_client, email=user.email, password="patientpassword") + + response = await async_client.get( + "/v1/patients/me/active-treatment", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(treatment.id) + assert data["regimen"] == "PB" + + +async def test_get_medication_checklist_returns_200( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + user, treatment = await _seed_patient_with_active_treatment( + db_session, + email="checklist-e2e@example.com", + ) + token = await _login_patient(async_client, email=user.email, password="patientpassword") + + response = await async_client.get( + "/v1/patients/me/medication-checklist", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["active_treatment_id"] == str(treatment.id) + assert data["can_register_doses"] is True diff --git a/backend/tests/e2e/test_v1_treatment_compatibility.py b/backend/tests/e2e/test_v1_treatment_compatibility.py new file mode 100644 index 0000000..a461826 --- /dev/null +++ b/backend/tests/e2e/test_v1_treatment_compatibility.py @@ -0,0 +1,131 @@ +"""E2E — contrato legado v1 de tratamentos e doses.""" + +from datetime import UTC, date, datetime +from uuid import UUID, uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.core.auth import create_access_token, hash_password +from pequi.models.dose_log import DoseLog +from pequi.models.health_professional import HealthProfessional +from pequi.models.health_unit import HealthUnit +from pequi.models.patient import PatientProfile +from pequi.models.user import User + +pytestmark = pytest.mark.asyncio + + +async def _seed_v1_actors( + db_session: AsyncSession, +) -> tuple[User, User, PatientProfile, HealthProfessional]: + unit = HealthUnit( + id=uuid4(), + name="UBS V1", + city="Cidade", + state="SP", + cnes=str(uuid4())[:11], + ) + db_session.add(unit) + await db_session.flush() + + patient_user = User( + id=uuid4(), + email=f"patient-v1-{uuid4()}@test.com", + username=f"patient_v1_{uuid4().hex[:8]}", + hashed_password=hash_password("patientpassword"), + full_name="Patient V1", + role="patient", + ) + professional_user = User( + id=uuid4(), + email=f"professional-v1-{uuid4()}@test.com", + username=f"professional_v1_{uuid4().hex[:8]}", + hashed_password=hash_password("professionalpassword"), + full_name="Professional V1", + role="health_professional", + ) + db_session.add_all([patient_user, professional_user]) + await db_session.flush() + + patient = PatientProfile( + id=uuid4(), + user_id=patient_user.id, + health_unit_id=unit.id, + date_of_birth=date(1990, 1, 1), + ) + professional = HealthProfessional( + id=uuid4(), + user_id=professional_user.id, + health_unit_id=unit.id, + ) + db_session.add_all([patient, professional]) + await db_session.flush() + return patient_user, professional_user, patient, professional + + +async def test_v1_treatment_contract_preserves_professional_fields( + create_tables, + async_client: AsyncClient, + db_session: AsyncSession, +): + patient_user, professional_user, patient, professional = await _seed_v1_actors(db_session) + professional_token = create_access_token(professional_user.id, "health_professional") + patient_token = create_access_token(patient_user.id, "patient") + + create_response = await async_client.post( + "/v1/treatments", + headers={"Authorization": f"Bearer {professional_token}"}, + json={ + "patient_id": str(patient.id), + "regimen": "PB", + "start_date": "2026-01-01", + "notes": "Tratamento legado", + }, + ) + assert create_response.status_code == 201 + created = create_response.json() + assert created["prescribed_by"] == str(professional.id) + + treatment_id = created["id"] + patient_get = await async_client.get( + f"/v1/treatments/{treatment_id}", + headers={"Authorization": f"Bearer {patient_token}"}, + ) + assert patient_get.status_code == 200 + assert patient_get.json()["prescribed_by"] == str(professional.id) + + professional_get = await async_client.get( + f"/v1/treatments/{treatment_id}", + headers={"Authorization": f"Bearer {professional_token}"}, + ) + assert professional_get.status_code == 200 + assert professional_get.json()["prescribed_by"] == str(professional.id) + + dose_response = await async_client.post( + f"/v1/treatments/{treatment_id}/doses", + headers={"Authorization": f"Bearer {professional_token}"}, + json={ + "drug_name": "Rifampicina", + "expected_at": "2026-02-01T08:00:00Z", + "taken_at": "2026-02-01T08:15:00Z", + "supervised": True, + }, + ) + assert dose_response.status_code == 201 + dose = dose_response.json() + assert dose["supervised"] is True + assert dose["registered_by"] == str(professional_user.id) + + persisted = await db_session.scalar( + select(DoseLog).where( + DoseLog.treatment_id == UUID(treatment_id), + DoseLog.drug_name == "Rifampicina", + DoseLog.expected_at == datetime(2026, 2, 1, 8, 0, tzinfo=UTC), + ) + ) + assert persisted is not None + assert persisted.supervised is True + assert persisted.registered_by == professional_user.id diff --git a/backend/tests/integration/test_account_deletion.py b/backend/tests/integration/test_account_deletion.py index 1fa0d9f..46b85f9 100644 --- a/backend/tests/integration/test_account_deletion.py +++ b/backend/tests/integration/test_account_deletion.py @@ -74,10 +74,8 @@ async def _create_professional(db_session: AsyncSession, unit_id) -> HealthProfe async def test_delete_account_blocks_active_treatment(create_tables, db_session: AsyncSession): user, patient = await _create_patient(db_session) - professional = await _create_professional(db_session, patient.health_unit_id) treatment = Treatment( patient_id=patient.id, - prescribed_by=professional.id, regimen=TreatmentRegimen.PB, start_date=date.today(), expected_end=date.today() + timedelta(days=180), diff --git a/backend/tests/integration/test_account_export_and_consents.py b/backend/tests/integration/test_account_export_and_consents.py index 9613e5d..e1b6445 100644 --- a/backend/tests/integration/test_account_export_and_consents.py +++ b/backend/tests/integration/test_account_export_and_consents.py @@ -11,6 +11,7 @@ from pequi.models.community import CommunityAnonymousMap, CommunityPost from pequi.models.consent import Consent from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent from pequi.models.patient import PatientProfile from pequi.models.user import User from pequi.schemas.account import ConsentCreate @@ -76,6 +77,15 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse categories=["experience"], ) db_session.add(post) + journey_event = JourneyEvent( + patient_id=patient.id, + event_type="clinical_improvement", + title="Melhora clinica", + description="Evento gerado para a jornada.", + occurred_at=datetime.now(UTC), + event_metadata={"source": "test"}, + ) + db_session.add(journey_event) await db_session.flush() exported = await ExportAccountDataUseCase(db_session).execute( @@ -90,6 +100,7 @@ async def test_export_account_data_includes_profile_clinical_community_and_conse assert exported["body_map_entries"] == [] assert exported["adherence_snapshots"] == [] assert exported["weekly_symptom_summaries"] == [] + assert exported["journey_events"][0]["event_type"] == "clinical_improvement" assert exported["community_posts"][0]["title"] == "Minha jornada" assert exported["community_posts"][0]["categories"] == ["experience"] assert "author_anonymous_id" not in exported["community_posts"][0] diff --git a/backend/tests/integration/test_adherence_worker.py b/backend/tests/integration/test_adherence_worker.py index e0378d1..4ba6afe 100644 --- a/backend/tests/integration/test_adherence_worker.py +++ b/backend/tests/integration/test_adherence_worker.py @@ -91,7 +91,6 @@ async def _create_patient_with_treatment( treatment = Treatment( id=treatment_id, patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=date(2026, 1, 1), expected_end=date(2026, 12, 31), @@ -240,7 +239,6 @@ async def test_adherence_repo_lists_active_treatments(db_session: AsyncSession): # Criar tratamento ativo active_treatment = Treatment( patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=date(2026, 1, 1), expected_end=date(2026, 12, 31), @@ -250,7 +248,6 @@ async def test_adherence_repo_lists_active_treatments(db_session: AsyncSession): # Criar tratamento completado completed_treatment = Treatment( patient_id=patient_id, - prescribed_by=professional_id, regimen="PB", start_date=date(2025, 1, 1), expected_end=date(2025, 6, 30), diff --git a/backend/tests/integration/test_alert_after_checkin.py b/backend/tests/integration/test_alert_after_checkin.py index 6ae1395..08c3595 100644 --- a/backend/tests/integration/test_alert_after_checkin.py +++ b/backend/tests/integration/test_alert_after_checkin.py @@ -54,8 +54,8 @@ async def test_missed_doses_alert_when_four_missed_in_week(create_tables, db_ses pu = await _create_user(db_session, email="al2@test.com", role="patient") prof = await _create_user(db_session, email="pr2@test.com", role="health_professional") patient = await _create_patient(db_session, user=pu, health_unit=hu) - professional = await _create_professional(db_session, user=prof, health_unit=hu) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + await _create_professional(db_session, user=prof, health_unit=hu) + treatment = await _create_treatment(db_session, patient=patient) symptom = await _symptom(db_session) now = datetime.now(UTC) for i in range(4): diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py index 6f3dad5..2bbfc40 100644 --- a/backend/tests/integration/test_body_map.py +++ b/backend/tests/integration/test_body_map.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -361,7 +361,7 @@ async def test_history_date_range_filter(async_client: AsyncClient, db_session: }, ) - today = date.today() + today = datetime.now(UTC).date() filtered = await async_client.get( "/v1/body-map/history", headers=headers, diff --git a/backend/tests/integration/test_dose_flow.py b/backend/tests/integration/test_dose_flow.py index 5584705..f75ba84 100644 --- a/backend/tests/integration/test_dose_flow.py +++ b/backend/tests/integration/test_dose_flow.py @@ -9,7 +9,12 @@ import pytest -from pequi.core.exceptions import ConflictError, ForbiddenError, NotFoundError +from pequi.core.exceptions import ( + ConflictError, + ForbiddenError, + NotFoundError, + ValidationFailedError, +) from pequi.models.dose_log import AdherenceSnapshot from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit @@ -17,17 +22,13 @@ from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus from pequi.models.user import User from pequi.repositories.dose_repo import DoseRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate from pequi.use_cases.get_adherence import GetAdherenceUseCase from pequi.use_cases.register_dose import RegisterDoseUseCase -# --------------------------------------------------------------------------- -# Helpers de fixtures -# --------------------------------------------------------------------------- - async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUnit: hu = HealthUnit(id=uuid4(), name=name, city="Cidade", state="SP", cnes=str(uuid4())[:11]) @@ -80,13 +81,11 @@ async def _create_treatment( session, *, patient: PatientProfile, - professional: HealthProfessional, status: TreatmentStatus = TreatmentStatus.active, ) -> Treatment: treatment = Treatment( id=uuid4(), patient_id=patient.id, - prescribed_by=professional.id, regimen=TreatmentRegimen.PB, start_date=date(2026, 1, 1), expected_end=date(2026, 7, 1), @@ -102,24 +101,17 @@ def _make_use_case(session) -> RegisterDoseUseCase: TreatmentRepository(session), DoseRepository(session), PatientRepository(session), - HealthProfessionalRepository(session), + JourneyEventRepository(session), ) -# --------------------------------------------------------------------------- -# Testes -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_patient_can_register_daily_dose(create_tables, db_session): """Paciente registra dose diária do próprio tratamento ativo com sucesso.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient1@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof1@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) data = DoseLogCreate( drug_name="Dapsona", @@ -128,129 +120,117 @@ async def test_patient_can_register_daily_dose(create_tables, db_session): ) use_case = _make_use_case(db_session) - result = await use_case.execute(patient_user.id, "patient", treatment.id, data) + result = await use_case.execute(patient_user.id, treatment.id, data) assert result.id is not None assert result.treatment_id == treatment.id assert result.drug_name == "Dapsona" - assert result.supervised is False - assert result.registered_by is None @pytest.mark.asyncio -async def test_duplicate_dose_returns_conflict(create_tables, db_session): - """Dose duplicada (treatment_id + drug_name + expected_at) lança ConflictError.""" +async def test_patient_can_register_monthly_dose(create_tables, db_session): + """Paciente pode registrar dose mensal do próprio tratamento ativo.""" health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof2@test.com", role="health_professional") + patient_user = await _create_user(db_session, email="patient1b@test.com", role="patient") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) - expected_at = datetime(2026, 3, 1, 8, 0, tzinfo=UTC) - data = DoseLogCreate(drug_name="Clofazimina", expected_at=expected_at) + data = DoseLogCreate( + drug_name="Rifampicina", + expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), + taken_at=datetime(2026, 2, 1, 10, 15, tzinfo=UTC), + ) use_case = _make_use_case(db_session) - await use_case.execute(patient_user.id, "patient", treatment.id, data) + result = await use_case.execute(patient_user.id, treatment.id, data) - with pytest.raises(ConflictError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + assert result.drug_name == "Rifampicina" + assert result.taken_at is not None @pytest.mark.asyncio -async def test_professional_from_another_unit_cannot_access(create_tables, db_session): - """Profissional de outra unidade não pode registrar dose no tratamento.""" - unit_a = await _create_health_unit(db_session, name="UBS Norte") - unit_b = await _create_health_unit(db_session, name="UBS Sul") - - patient_user = await _create_user(db_session, email="patient3@test.com", role="patient") - prof_a_user = await _create_user( - db_session, email="prof_a@test.com", role="health_professional" - ) - prof_b_user = await _create_user( - db_session, email="prof_b@test.com", role="health_professional" - ) - - patient = await _create_patient(db_session, user=patient_user, health_unit=unit_a) - prof_a = await _create_professional(db_session, user=prof_a_user, health_unit=unit_a) - await _create_professional(db_session, user=prof_b_user, health_unit=unit_b) - - treatment = await _create_treatment(db_session, patient=patient, professional=prof_a) +async def test_duplicate_dose_returns_conflict(create_tables, db_session): + """Dose duplicada (treatment_id + drug_name + expected_at) lança ConflictError.""" + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email="patient2@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), - ) + expected_at = datetime(2026, 3, 1, 8, 0, tzinfo=UTC) + data = DoseLogCreate(drug_name="Clofazimina", expected_at=expected_at) use_case = _make_use_case(db_session) + await use_case.execute(patient_user.id, treatment.id, data) - with pytest.raises(ForbiddenError): - await use_case.execute(prof_b_user.id, "health_professional", treatment.id, data) + with pytest.raises(ConflictError): + await use_case.execute(patient_user.id, treatment.id, data) @pytest.mark.asyncio -async def test_patient_cannot_register_supervised_dose_without_consultation( - create_tables, - db_session, -): - """Paciente não pode registrar dose supervisionada fora do fluxo de consulta.""" +async def test_active_treatment_unique_index_returns_conflict(create_tables, db_session): health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient4@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof4@test.com", role="health_professional") + patient_user = await _create_user( + db_session, + email=f"active-conflict-{uuid4()}@test.com", + role="patient", + ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + await _create_treatment(db_session, patient=patient) - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), - supervised=True, - via_consultation=False, + second_active = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 2, 1), + expected_end=date(2026, 8, 1), + status=TreatmentStatus.active, ) - use_case = _make_use_case(db_session) + with pytest.raises(ConflictError): + await TreatmentRepository(db_session).create(second_active) - with pytest.raises(ForbiddenError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + inactive = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2026, 2, 1), + expected_end=date(2026, 8, 1), + status=TreatmentStatus.suspended, + ) + created = await TreatmentRepository(db_session).create(inactive) + assert created.id == inactive.id @pytest.mark.asyncio -async def test_patient_registers_supervised_dose_via_consultation(create_tables, db_session): - """Paciente registra dose supervisionada ao informar consulta realizada.""" +async def test_other_patient_cannot_register_dose(create_tables, db_session): + """Paciente não pode registrar dose em tratamento de outro paciente.""" health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient4b@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof4b@test.com", role="health_professional") - patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + owner_user = await _create_user(db_session, email="owner@test.com", role="patient") + other_user = await _create_user(db_session, email="other@test.com", role="patient") + owner = await _create_patient(db_session, user=owner_user, health_unit=health_unit) + await _create_patient(db_session, user=other_user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=owner) data = DoseLogCreate( drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 10, 0, tzinfo=UTC), - taken_at=datetime(2026, 2, 1, 10, 15, tzinfo=UTC), - supervised=True, - via_consultation=True, + expected_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), ) use_case = _make_use_case(db_session) - result = await use_case.execute(patient_user.id, "patient", treatment.id, data) - assert result.supervised is True - assert result.registered_by is None + with pytest.raises(ForbiddenError): + await use_case.execute(other_user.id, treatment.id, data) @pytest.mark.asyncio async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, db_session): - """Paciente não pode autoregistrar em tratamento não-ativo.""" + """Paciente não pode registrar dose em tratamento não-ativo.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient5@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof5@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) treatment = await _create_treatment( db_session, patient=patient, - professional=professional, status=TreatmentStatus.completed, ) @@ -261,10 +241,8 @@ async def test_patient_cannot_register_dose_on_inactive_treatment(create_tables, use_case = _make_use_case(db_session) - from pequi.core.exceptions import ValidationFailedError - with pytest.raises(ValidationFailedError): - await use_case.execute(patient_user.id, "patient", treatment.id, data) + await use_case.execute(patient_user.id, treatment.id, data) @pytest.mark.asyncio @@ -272,10 +250,8 @@ async def test_get_adherence_returns_latest_snapshot(create_tables, db_session): """get_adherence retorna o snapshot mais recente quando disponível.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient6@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof6@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) snapshot = AdherenceSnapshot( id=uuid4(), @@ -294,9 +270,8 @@ async def test_get_adherence_returns_latest_snapshot(create_tables, db_session): use_case = GetAdherenceUseCase( TreatmentRepository(db_session), PatientRepository(db_session), - HealthProfessionalRepository(db_session), ) - result = await use_case.execute(patient_user.id, "patient", treatment.id) + result = await use_case.execute(patient_user.id, treatment.id) assert result.treatment_id == treatment.id assert result.total_doses == 30 @@ -308,40 +283,13 @@ async def test_get_adherence_raises_not_found_when_no_snapshot(create_tables, db """Sem snapshot calculado, get_adherence lança NotFoundError.""" health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email="patient7@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof7@test.com", role="health_professional") patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) use_case = GetAdherenceUseCase( TreatmentRepository(db_session), PatientRepository(db_session), - HealthProfessionalRepository(db_session), ) with pytest.raises(NotFoundError): - await use_case.execute(patient_user.id, "patient", treatment.id) - - -@pytest.mark.asyncio -async def test_professional_registers_supervised_dose(create_tables, db_session): - """Profissional registra dose supervisionada com registered_by preenchido.""" - health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email="patient8@test.com", role="patient") - prof_user = await _create_user(db_session, email="prof8@test.com", role="health_professional") - patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - treatment = await _create_treatment(db_session, patient=patient, professional=professional) - - data = DoseLogCreate( - drug_name="Rifampicina", - expected_at=datetime(2026, 2, 1, 9, 0, tzinfo=UTC), - taken_at=datetime(2026, 2, 1, 9, 15, tzinfo=UTC), - supervised=True, - ) - - use_case = _make_use_case(db_session) - result = await use_case.execute(prof_user.id, "health_professional", treatment.id, data) - - assert result.supervised is True - assert result.registered_by == prof_user.id + await use_case.execute(patient_user.id, treatment.id) diff --git a/backend/tests/integration/test_journey_flow.py b/backend/tests/integration/test_journey_flow.py new file mode 100644 index 0000000..14b7560 --- /dev/null +++ b/backend/tests/integration/test_journey_flow.py @@ -0,0 +1,250 @@ +"""Testes de integração para GET /v1/journey (via use case).""" + +from datetime import UTC, date, datetime +from uuid import uuid4 + +import pytest + +from pequi.core.exceptions import NotFoundError +from pequi.models.health_appointment import PatientHealthAppointment +from pequi.models.health_unit import HealthUnit +from pequi.models.journey_event import JourneyEvent +from pequi.models.patient import PatientProfile +from pequi.models.treatment import Treatment, TreatmentRegimen, TreatmentStatus +from pequi.models.user import User +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.dose_log import DoseLogCreate +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.register_dose import RegisterDoseUseCase + + +async def _create_health_unit(session, *, name: str = "UBS Central") -> HealthUnit: + hu = HealthUnit(id=uuid4(), name=name, city="Cidade", state="SP", cnes=str(uuid4())[:11]) + session.add(hu) + await session.flush() + return hu + + +async def _create_user(session, *, email: str) -> User: + username = email.split("@")[0].replace(".", "_")[:30] + user = User( + id=uuid4(), + email=email, + username=username, + hashed_password="$2b$12$placeholder", + full_name="Test User", + role="patient", + ) + session.add(user) + await session.flush() + return user + + +async def _create_patient(session, *, user: User, health_unit: HealthUnit) -> PatientProfile: + patient = PatientProfile( + id=uuid4(), + user_id=user.id, + health_unit_id=health_unit.id, + date_of_birth=date(1985, 3, 10), + classification="PB", + ) + session.add(patient) + await session.flush() + return patient + + +async def _create_treatment(session, *, patient: PatientProfile) -> Treatment: + treatment = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2025, 1, 10), + expected_end=date(2025, 7, 10), + status=TreatmentStatus.active, + ) + session.add(treatment) + await session.flush() + return treatment + + +def _journey_use_case(session) -> GetPatientJourneyUseCase: + return GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + DoseRepository(session), + HealthAppointmentRepository(session), + JourneyEventRepository(session), + ) + + +@pytest.mark.asyncio +async def test_journey_returns_timeline_with_doses(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey1@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) + + register = RegisterDoseUseCase( + TreatmentRepository(db_session), + DoseRepository(db_session), + PatientRepository(db_session), + JourneyEventRepository(db_session), + ) + await register.execute( + user.id, + treatment.id, + DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2025, 2, 5, 8, 0, tzinfo=UTC), + taken_at=datetime(2025, 2, 5, 8, 30, tzinfo=UTC), + ), + ) + + result = await _journey_use_case(db_session).execute(user.id) + + assert result.patient_id == patient.id + assert result.regimen == "PB" + assert result.start_date == date(2025, 1, 10) + assert result.expected_end == date(2025, 7, 10) + assert result.current_month >= 1 + assert len(result.months) == 6 + + dose_events = [ + event for month in result.months for event in month.events if event.type == "dose_taken" + ] + assert len(dose_events) == 1 + assert result.summary.completed_doses == 1 + assert result.summary.adherence_pct is None + persisted_event = await JourneyEventRepository(db_session).get_by_source( + "dose_log", + next( + event.id for event in await DoseRepository(db_session).list_by_treatment(treatment.id) + ), + ) + assert persisted_event is not None + assert persisted_event.event_type == "dose_registered" + + +@pytest.mark.asyncio +async def test_journey_includes_consultation_events(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey2@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + await _create_treatment(db_session, patient=patient) + + appointment = PatientHealthAppointment( + id=uuid4(), + patient_id=patient.id, + appointment_date=date(2025, 2, 20), + appointment_time="09:00", + location="UBS Norte", + appointment_type="consulta", + professional="Dr. Silva", + performed=True, + status="completed", + wants_follow_up_details=False, + ) + db_session.add(appointment) + await db_session.flush() + + result = await _journey_use_case(db_session).execute(user.id) + + consultation_events = [ + event + for month in result.months + for event in month.events + if event.type == "consultation_registered" + ] + assert len(consultation_events) == 1 + + +@pytest.mark.asyncio +async def test_journey_combines_dose_and_consultation_in_same_month(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey-combo@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + treatment = await _create_treatment(db_session, patient=patient) + + register = RegisterDoseUseCase( + TreatmentRepository(db_session), + DoseRepository(db_session), + PatientRepository(db_session), + JourneyEventRepository(db_session), + ) + await register.execute( + user.id, + treatment.id, + DoseLogCreate( + drug_name="Dapsona", + expected_at=datetime(2025, 2, 15, 8, 0, tzinfo=UTC), + taken_at=datetime(2025, 2, 15, 8, 30, tzinfo=UTC), + ), + ) + + appointment = PatientHealthAppointment( + id=uuid4(), + patient_id=patient.id, + appointment_date=date(2025, 2, 20), + appointment_time="09:00", + location="UBS Norte", + appointment_type="consulta", + performed=True, + status="completed", + wants_follow_up_details=False, + ) + db_session.add(appointment) + await db_session.flush() + + result = await _journey_use_case(db_session).execute(user.id) + + month_two = next(month for month in result.months if month.month_number == 2) + types = {event.type for event in month_two.events} + assert "dose_taken" in types + assert "consultation_registered" in types + + +@pytest.mark.asyncio +async def test_journey_raises_not_found_without_active_treatment(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey3@test.com") + await _create_patient(db_session, user=user, health_unit=health_unit) + + with pytest.raises(NotFoundError): + await _journey_use_case(db_session).execute(user.id) + + +@pytest.mark.asyncio +async def test_journey_excludes_events_from_another_treatment(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + user = await _create_user(db_session, email="journey-filter@test.com") + patient = await _create_patient(db_session, user=user, health_unit=health_unit) + active = await _create_treatment(db_session, patient=patient) + old_treatment = Treatment( + id=uuid4(), + patient_id=patient.id, + regimen=TreatmentRegimen.PB, + start_date=date(2024, 1, 10), + expected_end=date(2024, 7, 10), + status=TreatmentStatus.completed, + ) + db_session.add(old_treatment) + await db_session.flush() + await JourneyEventRepository(db_session).create( + JourneyEvent( + patient_id=patient.id, + treatment_id=old_treatment.id, + event_type="alert", + title="Evento antigo", + description="Nao pertence ao tratamento ativo.", + occurred_at=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + ) + ) + + result = await _journey_use_case(db_session).execute(user.id) + + assert result.treatment.id == active.id + assert all(event.title != "Evento antigo" for month in result.months for event in month.events) diff --git a/backend/tests/integration/test_patient_health_appointment.py b/backend/tests/integration/test_patient_health_appointment.py index 64c6960..cc478dd 100644 --- a/backend/tests/integration/test_patient_health_appointment.py +++ b/backend/tests/integration/test_patient_health_appointment.py @@ -1,13 +1,16 @@ -from datetime import date +from datetime import UTC, date, datetime from uuid import uuid4 import pytest +from sqlalchemy import func, select from pequi.core.auth import hash_password +from pequi.models.dose_log import DoseLog +from pequi.models.health_appointment import PatientHealthAppointment from pequi.models.user import User from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.health_professional_repo import HealthProfessionalRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.health_appointment import ( @@ -24,7 +27,6 @@ from tests.integration.test_dose_flow import ( _create_health_unit, _create_patient, - _create_professional, _create_treatment, _create_user, ) @@ -34,20 +36,13 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session): health_unit = await _create_health_unit(db_session) patient_user = await _create_user(db_session, email=f"appt-{uuid4()}@test.com", role="patient") - prof_user = await _create_user( - db_session, - email=f"prof-{uuid4()}@test.com", - role="health_professional", - ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - await _create_treatment(db_session, patient=patient, professional=professional) + await _create_treatment(db_session, patient=patient) patient_repo = PatientRepository(db_session) await SavePatientTreatmentRecordUseCase( patient_repo, TreatmentRepository(db_session), - HealthProfessionalRepository(db_session), ).execute( patient_user.id, PatientTreatmentRecordSave( @@ -62,8 +57,8 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session patient_repo, HealthAppointmentRepository(db_session), TreatmentRepository(db_session), - HealthProfessionalRepository(db_session), DoseRepository(db_session), + JourneyEventRepository(db_session), ) created = await create_uc.execute( patient_user.id, @@ -75,6 +70,7 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session performed=True, follow_up=AppointmentFollowUpDraftIn( register_supervised_dose=True, + update_dose_from_consultation=True, dose_scheme_rifampicina=True, dose_scheme_dapsone=True, ), @@ -95,90 +91,145 @@ async def test_create_appointment_with_supervised_dose(create_tables, db_session @pytest.mark.asyncio -async def test_complete_scheduled_appointment_updates_same_row(create_tables, db_session): +async def test_completed_appointment_survives_duplicate_supervised_dose(create_tables, db_session): health_unit = await _create_health_unit(db_session) - patient_user = await _create_user(db_session, email=f"upd-{uuid4()}@test.com", role="patient") - prof_user = await _create_user( + patient_user = await _create_user( db_session, - email=f"prof-u-{uuid4()}@test.com", - role="health_professional", + email=f"appt-duplicate-{uuid4()}@test.com", + role="patient", ) patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) - professional = await _create_professional(db_session, user=prof_user, health_unit=health_unit) - await _create_treatment(db_session, patient=patient, professional=professional) + treatment = await _create_treatment(db_session, patient=patient) + + duplicate = DoseLog( + treatment_id=treatment.id, + drug_name="Rifampicina", + expected_at=datetime(2026, 5, 20, 8, 0, tzinfo=UTC), + ) + db_session.add(duplicate) + await db_session.flush() + + created = await CreatePatientHealthAppointmentUseCase( + PatientRepository(db_session), + HealthAppointmentRepository(db_session), + TreatmentRepository(db_session), + DoseRepository(db_session), + JourneyEventRepository(db_session), + ).execute( + patient_user.id, + HealthAppointmentCreate( + appointment_date=date(2026, 5, 20), + appointment_time="09:30", + location="UBS Centro", + appointment_type="dose_supervisionada", + performed=True, + follow_up=AppointmentFollowUpDraftIn( + register_supervised_dose=True, + update_dose_from_consultation=True, + dose_scheme_rifampicina=True, + dose_scheme_dapsone=True, + ), + ), + ) + + appointment_count = await db_session.scalar( + select(func.count()) + .select_from(PatientHealthAppointment) + .where(PatientHealthAppointment.id == created.id) + ) + assert appointment_count == 1 + + doses = ( + ( + await db_session.execute( + select(DoseLog.drug_name).where(DoseLog.treatment_id == treatment.id) + ) + ) + .scalars() + .all() + ) + assert sorted(doses) == ["Dapsona", "Rifampicina"] + + await db_session.refresh(patient) + assert patient.treatment_record["scheme_rifampicina"] is True + assert patient.treatment_record["scheme_dapsone"] is True + + +@pytest.mark.asyncio +async def test_complete_scheduled_appointment_updates_same_row(create_tables, db_session): + health_unit = await _create_health_unit(db_session) + patient_user = await _create_user(db_session, email=f"upd-{uuid4()}@test.com", role="patient") + patient = await _create_patient(db_session, user=patient_user, health_unit=health_unit) + await _create_treatment(db_session, patient=patient) patient_repo = PatientRepository(db_session) appointment_repo = HealthAppointmentRepository(db_session) treatment_repo = TreatmentRepository(db_session) - professional_repo = HealthProfessionalRepository(db_session) dose_repo = DoseRepository(db_session) + journey_event_repo = JourneyEventRepository(db_session) scheduled = await CreatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ).execute( patient_user.id, HealthAppointmentCreate( - appointment_date=date(2026, 6, 27), - appointment_time="15:30", - location="UBS Centro", + appointment_date=date(2026, 4, 10), + appointment_time="14:00", + location="UBS Sul", appointment_type="consulta", performed=False, ), ) - assert scheduled.status == "scheduled" completed = await UpdatePatientHealthAppointmentUseCase( patient_repo, appointment_repo, treatment_repo, - professional_repo, dose_repo, + journey_event_repo, ).execute( patient_user.id, scheduled.id, HealthAppointmentCreate( - appointment_date=date(2026, 6, 27), - appointment_time="15:30", - location="UBS Centro", + appointment_date=date(2026, 4, 10), + appointment_time="14:00", + location="UBS Sul", appointment_type="consulta", performed=True, - follow_up=AppointmentFollowUpDraftIn(conduct="Retorno em 30 dias"), + notes="Consulta realizada", ), ) assert completed.id == scheduled.id - assert completed.status == "completed" assert completed.performed is True + assert completed.status == "completed" listed = await ListPatientHealthAppointmentsUseCase( patient_repo, appointment_repo, ).execute(patient_user.id) assert len(listed) == 1 - assert listed[0].id == scheduled.id - assert listed[0].status == "completed" + assert listed[0].performed is True @pytest.mark.asyncio async def test_list_appointments_empty_for_new_patient(create_tables, db_session): user = User( - id=uuid4(), email=f"new-{uuid4()}@test.com", - username=f"u_{uuid4().hex[:8]}", - hashed_password=hash_password("senha12345"), + username=f"new_{uuid4().hex[:8]}", + hashed_password=hash_password("secret"), full_name="Novo Paciente", role="patient", ) db_session.add(user) await db_session.flush() - patient_repo = PatientRepository(db_session) listed = await ListPatientHealthAppointmentsUseCase( - patient_repo, + PatientRepository(db_session), HealthAppointmentRepository(db_session), ).execute(user.id) assert listed == [] diff --git a/backend/tests/integration/test_patient_treatment_record.py b/backend/tests/integration/test_patient_treatment_record.py index d45753d..c4672ae 100644 --- a/backend/tests/integration/test_patient_treatment_record.py +++ b/backend/tests/integration/test_patient_treatment_record.py @@ -5,11 +5,9 @@ import pytest -from pequi.models.health_professional import HealthProfessional from pequi.models.health_unit import HealthUnit from pequi.models.treatment import TreatmentRegimen, TreatmentStatus from pequi.models.user import User -from pequi.repositories.health_professional_repo import HealthProfessionalRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.patient_treatment import PatientTreatmentRecordSave @@ -20,7 +18,8 @@ ) -async def _seed_professional(db_session) -> HealthProfessional: +@pytest.mark.asyncio +async def test_save_and_load_treatment_record(create_tables, db_session): unit = HealthUnit( id=uuid4(), name="UBS Teste", @@ -31,31 +30,6 @@ async def _seed_professional(db_session) -> HealthProfessional: db_session.add(unit) await db_session.flush() - user = User( - id=uuid4(), - email=f"prof-{uuid4()}@test.com", - username=f"prof_{uuid4().hex[:8]}", - hashed_password="x", - full_name="Profissional Teste", - role="health_professional", - ) - db_session.add(user) - await db_session.flush() - - professional = HealthProfessional( - id=uuid4(), - user_id=user.id, - health_unit_id=unit.id, - ) - db_session.add(professional) - await db_session.flush() - return professional - - -@pytest.mark.asyncio -async def test_save_and_load_treatment_record(create_tables, db_session): - await _seed_professional(db_session) - user = User( id=uuid4(), email=f"patient-{uuid4()}@test.com", @@ -69,12 +43,10 @@ async def test_save_and_load_treatment_record(create_tables, db_session): patient_repo = PatientRepository(db_session) treatment_repo = TreatmentRepository(db_session) - professional_repo = HealthProfessionalRepository(db_session) save_uc = SavePatientTreatmentRecordUseCase( patient_repo, treatment_repo, - professional_repo, ) payload = PatientTreatmentRecordSave( diagnosis_date=date(2025, 1, 10), diff --git a/backend/tests/integration/test_summary_worker.py b/backend/tests/integration/test_summary_worker.py index 6889214..4e7e258 100644 --- a/backend/tests/integration/test_summary_worker.py +++ b/backend/tests/integration/test_summary_worker.py @@ -88,7 +88,6 @@ async def test_summary_job_processes_active_patients(db_session: AsyncSession, m treatment = Treatment( id=treatment_id, patient_id=patient_id, - prescribed_by=professional_id, regimen="MB", start_date=datetime(2026, 1, 1).date(), expected_end=datetime(2026, 12, 31).date(), diff --git a/backend/tests/unit/test_journey_service.py b/backend/tests/unit/test_journey_service.py new file mode 100644 index 0000000..1bd7399 --- /dev/null +++ b/backend/tests/unit/test_journey_service.py @@ -0,0 +1,297 @@ +"""Testes unitários para JourneyService — sem banco, sem HTTP.""" + +from datetime import UTC, date, datetime +from decimal import Decimal +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from pequi.models.treatment import TreatmentRegimen, TreatmentStatus +from pequi.services.journey_service import JourneyService + + +def _treatment(*, regimen=TreatmentRegimen.PB, start=date(2025, 1, 10), end=date(2025, 7, 10)): + return SimpleNamespace( + id=uuid4(), + regimen=regimen, + start_date=start, + expected_end=end, + status=TreatmentStatus.active, + ) + + +def _dose(*, drug="Dapsona", expected=None, taken=None, skipped=False): + return SimpleNamespace( + id=uuid4(), + drug_name=drug, + expected_at=expected or datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + taken_at=taken, + skipped=skipped, + skip_reason=None, + ) + + +class TestJourneyServiceProgress: + def test_current_month_on_start_date(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2025, 1, 10), + 6, + ) + assert result == 1 + + def test_current_month_third_month(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2025, 3, 15), + 6, + ) + assert result == 3 + + def test_current_month_capped_at_total(self) -> None: + result = JourneyService.calculate_current_month( + date(2025, 1, 10), + date(2026, 1, 1), + 6, + ) + assert result == 6 + + def test_progress_pct_mid_treatment(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 3, 15), + ) + # 64 dias decorridos de 181 totais (10/jan a 10/jul) + assert result == Decimal("35.4") + + def test_progress_pct_at_start(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 1, 10), + ) + assert result == Decimal("0.0") + + def test_progress_pct_at_end(self) -> None: + result = JourneyService.calculate_progress_pct( + date(2025, 1, 10), + date(2025, 7, 10), + date(2025, 7, 10), + ) + assert result == Decimal("100.0") + + +class TestJourneyServiceTimeline: + def test_dose_taken_appears_as_event(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 5, 9, 0, tzinfo=UTC) + doses = [_dose(expected=taken_at, taken=taken_at)] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + dose_events = [ + event + for month in journey.months + for event in month.events + if event.type == "dose_taken" + ] + assert len(dose_events) == 1 + assert dose_events[0].title == "Dose tomada" + + def test_dose_skipped_appears_as_event(self) -> None: + treatment = _treatment() + expected = datetime(2025, 2, 5, 9, 0, tzinfo=UTC) + doses = [_dose(expected=expected, skipped=True)] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + skipped_events = [ + event + for month in journey.months + for event in month.events + if event.type == "dose_skipped" + ] + assert len(skipped_events) == 1 + + def test_consultation_appears_as_event(self) -> None: + treatment = _treatment() + appointment = SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional="Dr. Silva", + performed=True, + ) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[appointment], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + consultation_events = [ + event + for month in journey.months + for event in month.events + if event.type == "consultation_registered" + ] + assert len(consultation_events) == 1 + assert consultation_events[0].title == "Consulta registrada" + + def test_summary_counts_doses(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 1, 8, 0, tzinfo=UTC) + doses = [ + _dose(expected=taken_at, taken=taken_at), + _dose( + expected=datetime(2025, 2, 2, 8, 0, tzinfo=UTC), + taken=None, + ), + ] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.completed_doses == 1 + assert journey.summary.pending_doses == 1 + assert journey.summary.adherence_pct is None + + def test_summary_without_snapshot_does_not_calculate_adherence(self) -> None: + treatment = _treatment() + doses = [ + _dose( + expected=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + taken=datetime(2025, 2, 1, 8, 0, tzinfo=UTC), + ), + _dose( + expected=datetime(2025, 2, 2, 8, 0, tzinfo=UTC), + skipped=True, + ), + ] + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.completed_doses == 1 + assert journey.summary.skipped_doses == 1 + assert journey.summary.adherence_pct is None + + def test_dose_and_consultation_same_month_sort_without_error(self) -> None: + treatment = _treatment() + taken_at = datetime(2025, 2, 15, 9, 0, tzinfo=UTC) + doses = [_dose(expected=taken_at, taken=taken_at)] + appointment = SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional=None, + performed=True, + ) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=doses, + appointments=[appointment], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + month_two = next(month for month in journey.months if month.month_number == 2) + types = {event.type for event in month_two.events} + assert "dose_taken" in types + assert "consultation_registered" in types + + def test_months_grouped_by_calendar_month(self) -> None: + treatment = _treatment(regimen=TreatmentRegimen.PB) + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert len(journey.months) == 6 + assert journey.months[0].month_number == 6 + assert journey.months[-1].month_number == 1 + assert next(month for month in journey.months if month.is_current).month_number == 2 + + def test_summary_exposes_frontend_aggregates(self) -> None: + treatment = _treatment() + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[_dose(taken=datetime(2025, 2, 1, 8, 0, tzinfo=UTC))], + appointments=[ + SimpleNamespace( + appointment_date=date(2025, 2, 20), + appointment_type="consulta", + location="UBS Central", + professional=None, + performed=True, + ) + ], + adherence_snapshot=None, + today=date(2025, 3, 1), + ) + + assert journey.summary.total_months == 6 + assert journey.summary.current_month == 2 + assert journey.summary.total_consultations == 1 + assert journey.summary.total_doses_registered == 1 + + @pytest.mark.parametrize( + ("regimen", "expected_months"), + [ + (TreatmentRegimen.PB, 6), + (TreatmentRegimen.MB, 12), + ], + ) + def test_total_months_by_regimen(self, regimen, expected_months) -> None: + start = date(2025, 1, 1) + end = date(2025, 7, 1) if regimen == TreatmentRegimen.PB else date(2026, 1, 1) + treatment = _treatment(regimen=regimen, start=start, end=end) + + journey = JourneyService.build_journey( + patient_id=uuid4(), + treatment=treatment, + doses=[], + appointments=[], + adherence_snapshot=None, + today=start, + ) + + assert len(journey.months) == expected_months diff --git a/backend/tests/unit/test_treatment_use_cases.py b/backend/tests/unit/test_treatment_use_cases.py index 353d97d..a083673 100644 --- a/backend/tests/unit/test_treatment_use_cases.py +++ b/backend/tests/unit/test_treatment_use_cases.py @@ -4,7 +4,7 @@ import pytest -from pequi.core.exceptions import ForbiddenError, NotFoundError +from pequi.core.exceptions import ForbiddenError, NotFoundError, ValidationFailedError from pequi.schemas.treatment import TreatmentCreate from pequi.use_cases.create_treatment import CreateTreatmentUseCase from pequi.use_cases.get_treatment import GetTreatmentUseCase @@ -16,7 +16,6 @@ def _treatment_attrs(**overrides): base = { "id": uuid4(), "patient_id": uuid4(), - "prescribed_by": uuid4(), "regimen": "PB", "start_date": date(2026, 1, 1), "expected_end": date(2026, 7, 1), @@ -30,8 +29,9 @@ def _treatment_attrs(**overrides): class FakeTreatmentRepository: - def __init__(self, treatment=None): + def __init__(self, treatment=None, active=None): self.treatment = treatment + self.active = active self.created = None async def create(self, treatment): @@ -45,6 +45,11 @@ async def get_by_id(self, treatment_id): return None return self.treatment + async def get_active_by_patient_id(self, patient_id): + if self.active is None or self.active.patient_id != patient_id: + return None + return self.active + class FakePatientRepository: def __init__(self, *, by_id=None, by_user_id=None): @@ -62,31 +67,17 @@ async def get_by_user_id(self, user_id): return self.by_user_id -class FakeProfessionalRepository: - def __init__(self, professional=None): - self.professional = professional - - async def get_by_user_id(self, user_id): - if self.professional is None or self.professional.user_id != user_id: - return None - return self.professional - - async def test_create_treatment_calculates_expected_end_and_preserves_notes(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment_repo = FakeTreatmentRepository() use_case = CreateTreatmentUseCase( treatment_repo, - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=patient), ) result = await use_case.execute( - professional.user_id, + patient.user_id, TreatmentCreate( - patient_id=patient.id, regimen="PB", start_date=date(2026, 1, 31), notes="Tratamento inicial", @@ -94,7 +85,6 @@ async def test_create_treatment_calculates_expected_end_and_preserves_notes(): ) assert result.patient_id == patient.id - assert result.prescribed_by == professional.id assert result.expected_end == date(2026, 7, 31) assert result.status == "active" assert result.notes == "Tratamento inicial" @@ -102,159 +92,81 @@ async def test_create_treatment_calculates_expected_end_and_preserves_notes(): async def test_create_treatment_handles_month_end_for_mb_regimen(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) use_case = CreateTreatmentUseCase( FakeTreatmentRepository(), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=patient), ) result = await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=patient.id, regimen="MB", start_date=date(2024, 2, 29)), + patient.user_id, + TreatmentCreate(regimen="MB", start_date=date(2024, 2, 29)), ) assert result.expected_end == date(2025, 2, 28) assert result.regimen == "MB" -async def test_create_treatment_requires_existing_professional_profile(): - use_case = CreateTreatmentUseCase( - FakeTreatmentRepository(), - FakePatientRepository(), - FakeProfessionalRepository(None), - ) - - with pytest.raises(NotFoundError): - await use_case.execute( - uuid4(), - TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), - ) - - async def test_create_treatment_requires_existing_patient_profile(): - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) use_case = CreateTreatmentUseCase( FakeTreatmentRepository(), - FakePatientRepository(by_id=None), - FakeProfessionalRepository(professional), + FakePatientRepository(by_user_id=None), ) with pytest.raises(NotFoundError): await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=uuid4(), regimen="PB", start_date=date(2026, 1, 1)), + uuid4(), + TreatmentCreate(regimen="PB", start_date=date(2026, 1, 1)), ) -async def test_create_treatment_rejects_patient_from_another_unit(): - patient = SimpleNamespace(id=uuid4(), health_unit_id=uuid4()) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) +async def test_create_treatment_rejects_when_active_treatment_exists(): + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) + active = SimpleNamespace(id=uuid4(), patient_id=patient.id, status="active") use_case = CreateTreatmentUseCase( - FakeTreatmentRepository(), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), + FakeTreatmentRepository(active=active), + FakePatientRepository(by_user_id=patient), ) - with pytest.raises(ForbiddenError): + with pytest.raises(ValidationFailedError): await use_case.execute( - professional.user_id, - TreatmentCreate(patient_id=patient.id, regimen="PB", start_date=date(2026, 1, 1)), + patient.user_id, + TreatmentCreate(regimen="PB", start_date=date(2026, 1, 1)), ) async def test_get_treatment_allows_patient_owner(): - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) + patient = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) use_case = GetTreatmentUseCase( FakeTreatmentRepository(treatment), FakePatientRepository(by_user_id=patient), - FakeProfessionalRepository(), ) - result = await use_case.execute(patient.user_id, "patient", treatment.id) + result = await use_case.execute(patient.user_id, treatment.id) assert result.id == treatment.id assert result.patient_id == patient.id async def test_get_treatment_rejects_patient_that_is_not_owner(): - owner = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - actor = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=owner.health_unit_id) + owner = SimpleNamespace(id=uuid4(), user_id=uuid4()) + actor = SimpleNamespace(id=uuid4(), user_id=uuid4()) treatment = SimpleNamespace(**_treatment_attrs(patient_id=owner.id)) use_case = GetTreatmentUseCase( FakeTreatmentRepository(treatment), FakePatientRepository(by_user_id=actor), - FakeProfessionalRepository(), - ) - - with pytest.raises(ForbiddenError): - await use_case.execute(actor.user_id, "patient", treatment.id) - - -async def test_get_treatment_allows_professional_from_same_unit(): - unit_id = uuid4() - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=unit_id) - treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), - ) - - result = await use_case.execute(professional.user_id, "health_professional", treatment.id) - - assert result.id == treatment.id - - -async def test_get_treatment_rejects_professional_from_another_unit(): - patient = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - treatment = SimpleNamespace(**_treatment_attrs(patient_id=patient.id)) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=patient), - FakeProfessionalRepository(professional), - ) - - with pytest.raises(ForbiddenError): - await use_case.execute(professional.user_id, "health_professional", treatment.id) - - -async def test_get_treatment_rejects_unknown_actor_role(): - treatment = SimpleNamespace(**_treatment_attrs()) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(), - FakeProfessionalRepository(), ) with pytest.raises(ForbiddenError): - await use_case.execute(uuid4(), "admin", treatment.id) + await use_case.execute(actor.user_id, treatment.id) async def test_get_treatment_raises_not_found_for_missing_treatment(): use_case = GetTreatmentUseCase( FakeTreatmentRepository(None), FakePatientRepository(), - FakeProfessionalRepository(), - ) - - with pytest.raises(NotFoundError): - await use_case.execute(uuid4(), "patient", uuid4()) - - -async def test_get_treatment_raises_not_found_when_treatment_patient_disappears(): - professional = SimpleNamespace(id=uuid4(), user_id=uuid4(), health_unit_id=uuid4()) - treatment = SimpleNamespace(**_treatment_attrs()) - use_case = GetTreatmentUseCase( - FakeTreatmentRepository(treatment), - FakePatientRepository(by_id=None), - FakeProfessionalRepository(professional), ) with pytest.raises(NotFoundError): - await use_case.execute(professional.user_id, "health_professional", treatment.id) + await use_case.execute(uuid4(), uuid4()) diff --git a/docs/milestones/M3-treatments-doses.md b/docs/milestones/M3-treatments-doses.md index 4c6df06..0847c64 100644 --- a/docs/milestones/M3-treatments-doses.md +++ b/docs/milestones/M3-treatments-doses.md @@ -1,101 +1,63 @@ -# M3 — Treatments & Doses +# M3 - Treatments, Doses & Journey -> **Status:** 🔜 Pendente +> **Status:** Em implementacao > **Depende de:** M2 -> **Bloqueado por:** — ## Objetivo -Modelar o tratamento poliquimioterápico (MDT) do paciente com hanseníase — esquemas PB (6 meses) e MB (12 meses) —, o registro diário de doses e o cálculo de adesão. Ao final, é possível registrar doses tomadas/puladas e consultar o percentual de adesão. +Permitir que o paciente gerencie seu tratamento PB/MB, registre doses e acompanhe uma jornada +mensal unificada. Profissionais continuam podendo criar tratamentos e registrar doses +supervisionadas pelo contrato legado `/v1`. -## Modelo de dados +## Modelo +- `treatments.prescribed_by` e opcional para tratamentos patient-first criados em `/v2`. +- Campos profissionais permanecem disponiveis para compatibilidade `/v1`. +- Apenas um tratamento ativo e permitido por paciente. +- `dose_logs` impede duplicidade por tratamento, medicamento e horario esperado. +- Adesao e lida exclusivamente de `adherence_snapshots`. + +### Journey events + +`journey_events` persiste eventos clinicos unificados: + +```text +id, patient_id, treatment_id, event_type, title, description, +occurred_at, metadata, source_type, source_id, created_at ``` -symptoms ← catálogo seed-only -├── id UUID PK -├── name TEXT NOT NULL -├── category ENUM('dermatological','neurological','systemic') -└── description TEXT - -treatments -├── id UUID PK -├── patient_id UUID FK → patient_profiles(id) ON DELETE RESTRICT -├── prescribed_by UUID FK → health_professionals(id) ON DELETE RESTRICT -├── regimen ENUM('PB','MB') NOT NULL -├── start_date DATE NOT NULL -├── expected_end DATE NOT NULL ← calculado: PB+6m / MB+12m -├── status ENUM('active','completed','abandoned','suspended') -├── notes TEXT -├── created_at TIMESTAMPTZ -├── updated_at TIMESTAMPTZ -└── deleted_at TIMESTAMPTZ NULL - -dose_schedules ← um registro por fármaco por mês -├── id UUID PK -├── treatment_id UUID FK → treatments(id) ON DELETE RESTRICT -├── drug_name TEXT NOT NULL ← ex: "Rifampicina", "Dapsona", "Clofazimina" -├── frequency ENUM('daily','monthly_supervised') -├── dose_mg NUMERIC(6,2) -└── month_number SMALLINT ← 1..12 - -dose_logs -├── id UUID PK -├── treatment_id UUID FK → treatments(id) ON DELETE RESTRICT -├── drug_name TEXT NOT NULL -├── expected_at TIMESTAMPTZ NOT NULL -├── taken_at TIMESTAMPTZ NULL -├── skipped BOOLEAN DEFAULT false -├── skip_reason TEXT NULL -├── supervised BOOLEAN DEFAULT false ← dose supervisionada (mensal) -├── registered_by UUID NULL FK → users(id) ← profissional ou null (autoregistro) -└── created_at TIMESTAMPTZ - -adherence_snapshots ← calculado por worker, nunca em tempo real -├── id UUID PK -├── patient_id UUID FK → patient_profiles(id) -├── treatment_id UUID FK → treatments(id) -├── period_start DATE -├── period_end DATE -├── total_doses INT -├── taken_doses INT -├── adherence_pct NUMERIC(5,2) -└── calculated_at TIMESTAMPTZ -``` -## Arquivos criados - -| Camada | Arquivo | -|--------|---------| -| Models | `models/treatment.py`, `models/dose_log.py`, `models/symptom.py` | -| Schemas | `schemas/treatment.py`, `schemas/dose_log.py` | -| Repositories | `repositories/treatment_repo.py`, `repositories/dose_repo.py` | -| Services | `services/adherence_service.py` | -| Use Cases | `use_cases/register_dose.py`, `use_cases/get_adherence.py` | -| Router | `routers/treatment.py` | -| Tests | `tests/unit/test_adherence_service.py`, `tests/integration/test_dose_flow.py` | -| Bruno | `bruno/treatment/`, `bruno/dose/` | -| Migration | `alembic/versions/003_create_treatments.py` | - -## Endpoints - -| Método | Path | Rate Limit | Auth | -|--------|------|-----------|------| -| `POST` | `/v1/treatments` | 10/min | professional | -| `GET` | `/v1/treatments/{id}` | 100/min | patient/professional | -| `POST` | `/v1/treatments/{id}/doses` | 20/min | patient/professional | -| `GET` | `/v1/treatments/{id}/adherence` | 100/min | patient/professional | -| `GET` | `/v1/symptoms` | 200/min | any authenticated | - -## Regras de negócio - -- Adesão **nunca** calculada em tempo real — lida de `adherence_snapshots` -- `CASCADE DELETE` proibido em `dose_logs` e `treatments` -- Dose supervisionada mensal deve ser registrada por profissional (campo `registered_by` não nulo) -- Paciente só pode autoregistrar doses diárias do próprio tratamento ativo - -## Critérios de aceite - -- [ ] `AdherenceService.calculate_pct` cobre casos: 0%, 33.33%, 100% -- [ ] Registro de dose duplicada (mesma `expected_at` + `drug_name`) retorna 409 -- [ ] Profissional de outra unidade não acessa o tratamento -- [ ] Testes unitários e de integração passando +Tipos suportados pela modelagem incluem `consultation`, `dose_registered`, `treatment_started`, +`treatment_completed`, `clinical_improvement`, `clinical_worsening` e `alert`. + +O registro de uma dose cria automaticamente um evento `dose_registered` na mesma transacao. +`source_type` e `source_id` permitem idempotencia e integracao futura por workers de check-in. + +## Journey + +| Metodo | Path | Auth | Limite | +|---|---|---|---| +| `GET` | `/v1/patients/me/journey` | patient | 100/min | +| `GET` | `/v2/journey` | patient | 100/min | + +A resposta inclui blocos `patient`, `treatment` e `summary`, meses em ordem decrescente, +`month_number`, `is_current`, eventos unificados, progresso, consultas e doses registradas. + +## Regras + +- Nao calcular adesao em tempo real. +- Nao usar `CASCADE DELETE` em dados clinicos. +- Paciente acessa somente seu proprio tratamento e sua propria jornada. +- Dose duplicada retorna conflito sem desfazer outras alteracoes da transacao. +- Eventos futuros de check-in devem ser persistidos por worker em `journey_events`. + +## Criterios de aceite + +- [x] Tratamentos PB e MB calculam duracao esperada. +- [x] Dose duplicada retorna conflito. +- [x] Dose registrada cria evento persistido na Journey. +- [x] Journey agrupa eventos pelo mes correto. +- [x] Journey identifica o mes atual. +- [x] Journey retorna meses em ordem decrescente. +- [x] Resumo retorna progresso, consultas e doses registradas. +- [x] Modelagem suporta eventos clinicos futuros. +- [x] Testes unitarios, integracao e E2E cobrem o fluxo principal. diff --git a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts index 8b83dc8..4271ac7 100644 --- a/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts +++ b/frontend/src/app/components/checkin-step-feeling-component/checkin-step-feeling-component.ts @@ -31,7 +31,7 @@ export class CheckinStepFeelingComponent { bars: 5, }, { - value: 'muito-bem', + value: 'good', label: 'Muito Bem', emoji: '🙂', color: 'bg-[#5C9B7B]', diff --git a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html index d98f98a..5d513e8 100644 --- a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html +++ b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.html @@ -33,7 +33,7 @@

+

diff --git a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts index 5dcde43..92c1bed 100644 --- a/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts +++ b/frontend/src/app/components/checkin-step-symptoms-component/checkin-step-symptoms-component.ts @@ -2,12 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; -type SymptomOption = { - value: string; - label: string; - selectedClass: string; - unselectedClass: string; -}; +import type { SymptomOption } from '../../features/checkin/models/checkin.models'; @Component({ selector: 'app-checkin-step-symptoms-component', @@ -18,102 +13,11 @@ type SymptomOption = { }) export class CheckinStepSymptomsComponent { @Input({ required: true }) form!: FormGroup; + @Input({ required: true }) symptoms: SymptomOption[] = []; + @Input() loading = false; readonly noSymptomsValue = 'nenhum sintoma'; - symptoms: SymptomOption[] = [ - { - value: 'nenhum sintoma', - label: 'Nenhum sintoma hoje', - selectedClass: 'bg-[#C0B9FF] border-[#C0B9FF] text-white', - unselectedClass: 'bg-[#4338CA] border-[#4338CA] opacity-80 text-white', - }, - { - value: 'dormencia', - label: 'Dormência', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'feridas na pele', - label: 'Feridas na pele', - selectedClass: 'bg-[#CFF2D9] border-[#A9E2BC] text-[#2F6B45]', - unselectedClass: 'bg-[#EEF9F1] border-[#CBEBD4] text-[#44403C]', - }, - { - value: 'pele seca', - label: 'Pele seca', - selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', - unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', - }, - { - value: 'formigamento', - label: 'Formigamento', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'fraqueza muscular', - label: 'Fraqueza muscular', - selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', - unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', - }, - { - value: 'nodulos', - label: 'Nódulos', - selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', - unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', - }, - { - value: 'problemas de visao', - label: 'Problemas de visão', - selectedClass: 'bg-[#ECE9FF] border-[#D3CDF8] text-[#4B3B8F]', - unselectedClass: 'bg-[#F7F5FF] border-[#E2DCF8] text-[#44403C]', - }, - { - value: 'vermelhidao', - label: 'Vermelhidão', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - { - value: 'mudança de cor da pele', - label: 'Mudança de cor da pele', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - { - value: 'coceira', - label: 'Coceira', - selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', - unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', - }, - { - value: 'suor frio', - label: 'Suor frio', - selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', - unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', - }, - { - value: 'escamação', - label: 'Escamação', - selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', - unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', - }, - { - value: 'sangramento', - label: 'Sangramento', - selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', - unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', - }, - { - value: 'perda de sensibilidade na pele', - label: 'Perda de sensibilidade na pele', - selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', - unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', - }, - ]; - get selectedSymptoms(): string[] { return this.form.get('selectedSymptoms')?.value ?? []; } diff --git a/frontend/src/app/features/checkin/checkin.html b/frontend/src/app/features/checkin/checkin.html index 19ea338..82a8efd 100644 --- a/frontend/src/app/features/checkin/checkin.html +++ b/frontend/src/app/features/checkin/checkin.html @@ -25,11 +25,12 @@

Check-in

[form]="feelingForm" > - - + { let fixture: ComponentFixture; let component: CheckinComponent; + let router: { navigate: ReturnType }; let checkinService: { listSymptoms: ReturnType; submit: ReturnType; resolveSymptomIds: ReturnType; + buildSymptomOptions: ReturnType; + }; + let medicationDataService: { + getMedicationChecklist: ReturnType; }; let toastService: { success: ReturnType; @@ -69,10 +77,46 @@ describe(CheckinComponent.name, () => { }; const mockSymptoms = [ - { id: 'symptom-1', name: 'Nenhum sintoma hoje', category: 'systemic' }, - { id: 'symptom-2', name: 'Dormência', category: 'neurological' }, + { + id: 'symptom-none', + name: 'Nenhum sintoma', + category: 'systemic', + description: 'Sem sintomas', + }, + { + id: 'symptom-1', + name: 'Dormência', + category: 'neurological', + description: 'Dormência', + }, ]; + const mockSymptomOptions = [ + { + id: 'symptom-none', + value: 'Nenhum sintoma', + label: 'Nenhum sintoma hoje', + category: 'systemic', + description: 'Sem sintomas', + selectedClass: 'selected-none', + unselectedClass: 'unselected-none', + }, + { + id: 'symptom-1', + value: 'Dormência', + label: 'Dormência', + category: 'neurological', + description: 'Dormência', + selectedClass: 'selected-default', + unselectedClass: 'unselected-default', + }, + ]; + + const mockChecklist = { + institutedMedications: [], + currentDoseMedication: null, + }; + const getByTestId = (testId: string) => fixture.debugElement.query(By.css(`[data-testid="${testId}"]`)); @@ -86,15 +130,25 @@ describe(CheckinComponent.name, () => { checkinService = { listSymptoms: vi.fn(() => of(mockSymptoms)), + buildSymptomOptions: vi.fn(() => mockSymptomOptions), submit: vi.fn(() => of({ id: 'checkin-1' })), resolveSymptomIds: vi.fn((selected: string[]) => { if (selected.includes('nenhum sintoma')) { + return ['symptom-none']; + } + + if (selected.includes('Dormência') || selected.includes('dormencia')) { return ['symptom-1']; } - return ['symptom-2']; + + return ['symptom-1']; }), }; + medicationDataService = { + getMedicationChecklist: vi.fn(() => of(mockChecklist)), + }; + toastService = { success: vi.fn(), error: vi.fn(), @@ -105,6 +159,7 @@ describe(CheckinComponent.name, () => { providers: [ { provide: Router, useValue: router }, { provide: CheckinService, useValue: checkinService }, + { provide: MedicationDataService, useValue: medicationDataService }, { provide: ToastService, useValue: toastService }, ], }) @@ -137,6 +192,19 @@ describe(CheckinComponent.name, () => { expect(component).toBeTruthy(); }); + it('should load symptoms and symptom options on init', () => { + expect(checkinService.listSymptoms).toHaveBeenCalled(); + expect(checkinService.buildSymptomOptions).toHaveBeenCalledWith(mockSymptoms); + expect(component.symptomCatalog()).toEqual(mockSymptoms); + expect(component.symptomOptions()).toEqual(mockSymptomOptions); + expect(component.symptomsLoading()).toBe(false); + }); + + it('should load medication reminder when there are no medications', () => { + expect(medicationDataService.getMedicationChecklist).toHaveBeenCalled(); + expect(component.medicationReminder()).toContain('Cadastre medicamentos e frequência'); + }); + it('should start on step 1', () => { expect(component.currentStep()).toBe(1); expect(component.currentStepNumber()).toBe(1); @@ -165,12 +233,6 @@ describe(CheckinComponent.name, () => { expect(prevButton.disabled).toBe(true); }); - it('should disable next button when current step is invalid', () => { - const [, nextButton] = getButtons(); - expect(component.isCurrentStepInvalid()).toBe(true); - expect(nextButton.disabled).toBe(true); - }); - it('should expose subforms correctly', () => { expect(component.feelingForm).toBeTruthy(); expect(component.symptomsForm).toBeTruthy(); @@ -245,7 +307,7 @@ describe(CheckinComponent.name, () => { it('should advance from step 2 to step 3 when symptoms form has regular symptoms', () => { component.currentStep.set(2); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.nextStep(); @@ -282,7 +344,7 @@ describe(CheckinComponent.name, () => { }); it('should keep intensity required when there are symptoms', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['headache']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); const scaleControl = component.intensityForm.get('scale'); @@ -313,14 +375,14 @@ describe(CheckinComponent.name, () => { component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); expect(scaleControl?.hasValidator(Validators.required)).toBe(false); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); expect(scaleControl?.hasValidator(Validators.required)).toBe(true); expect(component.intensityForm.invalid).toBe(true); }); it('should not advance from step 3 when intensity is required and invalid', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(3); fixture.detectChanges(); @@ -331,7 +393,7 @@ describe(CheckinComponent.name, () => { }); it('should advance from step 3 to step 4 when intensity is valid', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(3); component.intensityForm.get('scale')?.setValue(4); @@ -341,7 +403,7 @@ describe(CheckinComponent.name, () => { }); it('should go back from step 4 to step 3 in regular flow', () => { - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.currentStep.set(4); component.prevStep(); @@ -400,97 +462,208 @@ describe(CheckinComponent.name, () => { component.currentStep.set(3); fixture.detectChanges(); - const [, nextButton] = getButtons(); + const buttons = getButtons(); + const nextButton = buttons[1]; expect(nextButton.textContent?.trim()).toBe('Próximo'); }); - it('should show "Enviar registro" button on last step', () => { + it('should show "Enviar formulário" button on last step', () => { component.currentStep.set(4); fixture.detectChanges(); - const [, submitButton] = getButtons(); - expect(submitButton.textContent?.trim()).toBe('Enviar registro'); + const buttons = getButtons(); + const submitButton = buttons[1]; + expect(submitButton.textContent?.trim()).toBe('Enviar formulário'); }); - it('should keep next button disabled on invalid required steps', () => { + it('should keep next button enabled because template does not bind disabled state', () => { component.currentStep.set(1); - expect(component.feelingForm.invalid).toBe(true); + fixture.detectChanges(); - component.currentStep.set(2); - expect(component.symptomsForm.invalid).toBe(true); + const buttons = getButtons(); + const nextButton = buttons[1]; - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); - component.currentStep.set(3); - expect(component.intensityForm.invalid).toBe(true); + expect(component.isCurrentStepInvalid()).toBe(true); + expect(nextButton.disabled).toBe(false); }); it('should enable submit button on step 4 because details is optional', () => { component.currentStep.set(4); fixture.detectChanges(); - const [, submitButton] = getButtons(); + const buttons = getButtons(); + const submitButton = buttons[1]; + expect(component.isCurrentStepInvalid()).toBe(false); expect(submitButton.disabled).toBe(false); }); + it('should not submit when symptoms are still loading', () => { + component.symptomsLoading.set(true); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + }); + + it('should not submit when symptom catalog is empty', () => { + component.symptomsLoading.set(false); + component.symptomCatalog.set([]); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + }); + it('should not submit when the full form is invalid', () => { + component.symptomsLoading.set(false); + component.submit(); + expect(checkinService.submit).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); it('should mark full form as touched when submit is called with invalid form', () => { + component.symptomsLoading.set(false); + component.submit(); expect(component.form.touched).toBe(true); }); - it('should submit and navigate to home when form is valid in regular flow', () => { + it('should not submit when resolveSymptomIds returns empty array', () => { + checkinService.resolveSymptomIds.mockReturnValue([]); + component.feelingForm.get('mood')?.setValue('good'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(2); + + component.submit(); + + expect(checkinService.submit).not.toHaveBeenCalled(); + expect(toastService.error).toHaveBeenCalledWith( + 'Não foi possível identificar os sintomas', + 'Confira se o catálogo foi carregado corretamente e tente novamente.', + ); + }); + + it('should submit and navigate to medication when form is valid in regular flow', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.intensityForm.get('scale')?.setValue(1); component.detailsForm.get('notes')?.setValue('feeling well'); component.submit(); - expect(checkinService.submit).toHaveBeenCalled(); + expect(checkinService.resolveSymptomIds).toHaveBeenCalledWith( + ['Dormência'], + mockSymptoms, + ); + expect(checkinService.submit).toHaveBeenCalledWith({ + mood: 'good', + symptom_intensity: 1, + symptom_ids: ['symptom-1'], + general_notes: 'feeling well', + }); expect(toastService.success).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(router.navigate).toHaveBeenCalledWith(['/medication']); }); - it('should submit and navigate to home when "nenhum sintoma" skips intensity', () => { + it('should submit and navigate to medication when "nenhum sintoma" skips intensity', () => { component.feelingForm.get('mood')?.setValue('good'); component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); component.detailsForm.get('notes')?.setValue('sem sintomas hoje'); component.submit(); + expect(checkinService.submit).toHaveBeenCalledWith({ + mood: 'good', + symptom_intensity: 0, + symptom_ids: ['symptom-none'], + general_notes: 'sem sintomas hoje', + }); + expect(toastService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/medication']); + }); + + it('should trim notes before submitting', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); + component.detailsForm.get('notes')?.setValue(' observação '); + + component.submit(); + expect(checkinService.submit).toHaveBeenCalledWith( expect.objectContaining({ - mood: 'good', - symptom_intensity: 0, - symptom_ids: ['symptom-1'], + general_notes: 'observação', }), ); - expect(router.navigate).toHaveBeenCalledWith(['/home']); }); - it('should submit payload with only selectedSymptoms inside symptoms object', () => { - component.feelingForm.get('mood')?.setValue('sad'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['nausea']); - component.symptomsForm.get('customSymptom')?.setValue('other symptom'); + it('should submit null notes when details notes is empty', () => { + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.intensityForm.get('scale')?.setValue(5); - component.detailsForm.get('notes')?.setValue('extra notes'); + component.detailsForm.get('notes')?.setValue(' '); component.submit(); + + expect(checkinService.submit).toHaveBeenCalledWith( + expect.objectContaining({ + general_notes: null, + }), + ); }); - it('should submit payload with null intensity when "nenhum sintoma" is selected', () => { + it('should show conflict toast when api returns 409', () => { + checkinService.submit.mockReturnValue( + throwError(() => ({ + status: 409, + error: { detail: 'Já existe um check-in registrado para hoje.' }, + })), + ); + component.feelingForm.get('mood')?.setValue('good'); - component.symptomsForm.get('selectedSymptoms')?.setValue(['nenhum sintoma']); - component.detailsForm.get('notes')?.setValue('sem observações'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); component.submit(); + + expect(toastService.error).toHaveBeenCalledWith( + 'Você já registrou seu check-in hoje', + 'Já existe um check-in registrado para hoje.', + ); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should show generic error toast when api returns non-409 error', () => { + checkinService.submit.mockReturnValue( + throwError(() => ({ + status: 500, + })), + ); + + component.feelingForm.get('mood')?.setValue('good'); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); + component.intensityForm.get('scale')?.setValue(5); + + component.submit(); + + expect(toastService.error).toHaveBeenCalledWith( + 'Erro ao enviar check-in', + 'Tente novamente em instantes.', + ); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should follow the regular flow without skipping when there are symptoms', () => { @@ -498,7 +671,7 @@ describe(CheckinComponent.name, () => { component.nextStep(); expect(component.currentStep()).toBe(2); - component.symptomsForm.get('selectedSymptoms')?.setValue(['cough']); + component.symptomsForm.get('selectedSymptoms')?.setValue(['Dormência']); component.nextStep(); expect(component.currentStep()).toBe(3); diff --git a/frontend/src/app/features/checkin/checkin.ts b/frontend/src/app/features/checkin/checkin.ts index c3084ed..d486818 100644 --- a/frontend/src/app/features/checkin/checkin.ts +++ b/frontend/src/app/features/checkin/checkin.ts @@ -1,12 +1,14 @@ import { CommonModule } from '@angular/common'; import { Component, + WritableSignal, computed, effect, inject, + OnDestroy, OnInit, signal, - WritableSignal, + Injector, } from '@angular/core'; import { FormBuilder, @@ -15,12 +17,14 @@ import { Validators, } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { CheckinStepDetailsComponent } from '../../components/checkin-step-details-component/checkin-step-details-component'; import { CheckinStepFeelingComponent } from '../../components/checkin-step-feeling-component/checkin-step-feeling-component'; -import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { CheckinStepIntensityComponent } from '../../components/checkin-step-intensity-component/checkin-step-intensity-component'; -import { CheckinStepDetailsComponent } from '../../components/checkin-step-details-component/checkin-step-details-component'; +import { CheckinStepSymptomsComponent } from '../../components/checkin-step-symptoms-component/checkin-step-symptoms-component'; import { ToastService } from '../../components/toast/toast.service'; -import type { SymptomResponse } from './models/checkin.models'; +import type { SymptomOption, SymptomResponse } from './models/checkin.models'; import { CheckinService } from './services/checkin.service'; import { MedicationDataService } from '../medication/services/medication-data.service'; @@ -44,17 +48,20 @@ type StepItem = { templateUrl: './checkin.html', styleUrl: './checkin.css', }) -export class CheckinComponent implements OnInit { +export class CheckinComponent implements OnInit, OnDestroy { private readonly fb = inject(FormBuilder); private readonly router = inject(Router); private readonly checkinService = inject(CheckinService); private readonly medicationData = inject(MedicationDataService); private readonly toast = inject(ToastService); + private readonly injector = inject(Injector); private readonly NO_SYMPTOM_VALUE = 'nenhum sintoma'; readonly symptomCatalog = signal([]); + readonly symptomOptions = signal([]); readonly submitting = signal(false); + readonly symptomsLoading = signal(true); readonly medicationReminder = signal(null); steps: StepItem[] = [ @@ -88,15 +95,21 @@ export class CheckinComponent implements OnInit { const step = this.currentStep(); return (step / this.steps.length) * 100; }); - stepStatusSubscription: any; - symptomsSelectionSubscription: import("rxjs").Subscription | undefined; - constructor() {} + private stepStatusSubscription?: Subscription; + private symptomsSelectionSubscription?: Subscription; ngOnInit(): void { + this.setupIntensityConditionalValidation(); + this.checkinService.listSymptoms().subscribe({ - next: symptoms => this.symptomCatalog.set(symptoms), + next: symptoms => { + this.symptomCatalog.set(symptoms); + this.symptomOptions.set(this.checkinService.buildSymptomOptions(symptoms)); + this.symptomsLoading.set(false); + }, error: () => { + this.symptomsLoading.set(false); this.toast.error( 'Erro ao carregar sintomas', 'Verifique sua conexão e tente novamente.', @@ -105,15 +118,18 @@ export class CheckinComponent implements OnInit { }); this.medicationData.getMedicationChecklist().subscribe({ - next: (checklist) => { + next: checklist => { const total = - checklist.institutedMedications.length + (checklist.currentDoseMedication ? 1 : 0); + checklist.institutedMedications.length + + (checklist.currentDoseMedication ? 1 : 0); + if (total === 0) { this.medicationReminder.set( 'Cadastre medicamentos e frequência em Meu tratamento para ver os lembretes em Remédios.', ); return; } + this.medicationReminder.set( `Você tem ${total} medicamento(s) no plano. Em Remédios, os avisos seguem a frequência de cada um.`, ); @@ -122,6 +138,11 @@ export class CheckinComponent implements OnInit { }); } + ngOnDestroy(): void { + this.stepStatusSubscription?.unsubscribe(); + this.symptomsSelectionSubscription?.unsubscribe(); + } + get currentStepNumber(): WritableSignal { return this.currentStep; } @@ -213,6 +234,14 @@ export class CheckinComponent implements OnInit { } submit(): void { + if (this.symptomsLoading() || this.symptomCatalog().length === 0) { + this.toast.error( + 'Os sintomas ainda estão carregando', + 'Aguarde alguns instantes e tente novamente.', + ); + return; + } + if (this.form.invalid) { this.form.markAllAsTouched(); return; @@ -221,6 +250,7 @@ export class CheckinComponent implements OnInit { const rawValue = this.form.getRawValue(); const selectedSymptoms = rawValue.symptoms.selectedSymptoms ?? []; const noSymptomsSelected = selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); + const symptomIds = this.checkinService.resolveSymptomIds( selectedSymptoms, this.symptomCatalog(), @@ -229,7 +259,7 @@ export class CheckinComponent implements OnInit { if (symptomIds.length === 0) { this.toast.error( 'Não foi possível identificar os sintomas', - 'Aguarde o carregamento do catálogo ou selecione outra opção.', + 'Confira se o catálogo foi carregado corretamente e tente novamente.', ); return; } @@ -246,6 +276,8 @@ export class CheckinComponent implements OnInit { general_notes: rawValue.details.notes?.trim() || null, }; + console.log('ISSO QUE O FRONT MANDA: ',payload); + this.submitting.set(true); this.checkinService.submit(payload).subscribe({ next: () => { @@ -256,13 +288,22 @@ export class CheckinComponent implements OnInit { ); void this.router.navigate(['/medication']); }, - error: () => { + error: (errorResponse) => { this.submitting.set(false); + + if (errorResponse.status === 409) { + this.toast.error( + 'Você já registrou seu check-in hoje', + errorResponse.error?.detail ?? 'Você já registrou seu check-in diário.', + ); + return; + } + this.toast.error( 'Erro ao enviar check-in', 'Tente novamente em instantes.', ); - }, + } }); } @@ -292,29 +333,27 @@ export class CheckinComponent implements OnInit { return selectedSymptoms.includes(this.NO_SYMPTOM_VALUE); } - private setupCurrentStepValidationWatcher(): void { - effect(() => { - const step = this.currentStep(); - const currentGroup = this.getStepForm(step); + private readonly currentStepValidationEffect = effect(() => { + const step = this.currentStep(); + const currentGroup = this.getStepForm(step); - this.stepStatusSubscription?.unsubscribe(); - this.isCurrentStepInvalid.set(currentGroup.invalid); + this.stepStatusSubscription?.unsubscribe(); + this.isCurrentStepInvalid.set(currentGroup.invalid); - this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { - this.isCurrentStepInvalid.set(currentGroup.invalid); - }); + this.stepStatusSubscription = currentGroup.statusChanges.subscribe(() => { + this.isCurrentStepInvalid.set(currentGroup.invalid); }); - } + }, { injector: this.injector }); private setupIntensityConditionalValidation(): void { const selectedSymptomsControl = this.symptomsForm.get('selectedSymptoms'); - const intensityScaleControl = this.intensityForm.get('scale'); this.applyIntensityValidation(); - this.symptomsSelectionSubscription = selectedSymptomsControl?.valueChanges.subscribe(() => { - this.applyIntensityValidation(); - }); + this.symptomsSelectionSubscription = + selectedSymptomsControl?.valueChanges.subscribe(() => { + this.applyIntensityValidation(); + }); } private applyIntensityValidation(): void { diff --git a/frontend/src/app/features/checkin/models/checkin.models.ts b/frontend/src/app/features/checkin/models/checkin.models.ts index 1c0980d..4e6b42f 100644 --- a/frontend/src/app/features/checkin/models/checkin.models.ts +++ b/frontend/src/app/features/checkin/models/checkin.models.ts @@ -2,23 +2,31 @@ export interface SymptomResponse { id: string; name: string; category: string; - description?: string; + description: string | null; } export interface CheckinCreate { mood: string; symptom_intensity: number; symptom_ids: string[]; - general_notes?: string | null; + general_notes: string | null; } export interface CheckinResponse { id: string; - patient_id: string; mood: string; symptom_intensity: number; symptom_ids: string[]; general_notes: string | null; - checked_in_at: string; created_at: string; } + +export type SymptomOption = { + id: string; + value: string; + label: string; + category: string; + description: string | null; + selectedClass: string; + unselectedClass: string; +}; \ No newline at end of file diff --git a/frontend/src/app/features/checkin/services/checkin.service.ts b/frontend/src/app/features/checkin/services/checkin.service.ts index 278b139..c501fed 100644 --- a/frontend/src/app/features/checkin/services/checkin.service.ts +++ b/frontend/src/app/features/checkin/services/checkin.service.ts @@ -3,10 +3,20 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../../../environments/environment'; -import type { CheckinCreate, CheckinResponse, SymptomResponse } from '../models/checkin.models'; +import type { + CheckinCreate, + CheckinResponse, + SymptomOption, + SymptomResponse, +} from '../models/checkin.models'; const NO_SYMPTOM_VALUE = 'nenhum sintoma'; +type SymptomStyle = { + selectedClass: string; + unselectedClass: string; +}; + @Injectable({ providedIn: 'root', }) @@ -14,6 +24,69 @@ export class CheckinService { private readonly http = inject(HttpClient); private readonly apiUrl = environment.apiUrl; + private readonly symptomStyleMap: Record = { + 'nenhum sintoma': { + selectedClass: 'bg-[#C0B9FF] border-[#C0B9FF] text-white', + unselectedClass: 'bg-[#4338CA] border-[#4338CA] opacity-80 text-white', + }, + dormencia: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + 'feridas na pele': { + selectedClass: 'bg-[#CFF2D9] border-[#A9E2BC] text-[#2F6B45]', + unselectedClass: 'bg-[#EEF9F1] border-[#CBEBD4] text-[#44403C]', + }, + 'pele seca': { + selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', + unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', + }, + formigamento: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + 'fraqueza muscular': { + selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', + unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', + }, + nodulos: { + selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', + unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', + }, + 'problemas de visao': { + selectedClass: 'bg-[#ECE9FF] border-[#D3CDF8] text-[#4B3B8F]', + unselectedClass: 'bg-[#F7F5FF] border-[#E2DCF8] text-[#44403C]', + }, + vermelhidao: { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + 'mudanca de cor da pele': { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + coceira: { + selectedClass: 'bg-[#E3F1F5] border-[#C9E0E7] text-[#315C66]', + unselectedClass: 'bg-[#EFF8FA] border-[#D9E9ED] text-[#44403C]', + }, + 'suor frio': { + selectedClass: 'bg-[#E4F4E4] border-[#CBE6CB] text-[#446044]', + unselectedClass: 'bg-[#F2FAF2] border-[#DCECDC] text-[#44403C]', + }, + escamacao: { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }, + sangramento: { + selectedClass: 'bg-[#DFF1F5] border-[#BEDDE4] text-[#315C66]', + unselectedClass: 'bg-[#EDF7F9] border-[#D2E8ED] text-[#44403C]', + }, + 'perda de sensibilidade na pele': { + selectedClass: 'bg-[#F9E6E6] border-[#EFCACA] text-[#8A4A4A]', + unselectedClass: 'bg-[#FCF1F1] border-[#F1DADA] text-[#44403C]', + }, + }; + listSymptoms(): Observable { return this.http.get(`${this.apiUrl}/v1/symptoms`); } @@ -22,26 +95,48 @@ export class CheckinService { return this.http.post(`${this.apiUrl}/v1/checkins`, payload); } - resolveSymptomIds(selectedSlugs: string[], catalog: SymptomResponse[]): string[] { - if (selectedSlugs.includes(NO_SYMPTOM_VALUE)) { - const noneSymptom = catalog.find(symptom => - this.normalize(symptom.name).includes('nenhum'), + resolveSymptomIds(selectedNames: string[], catalog: SymptomResponse[]): string[] { + if (!catalog.length) { + return []; + } + + if (selectedNames.includes(NO_SYMPTOM_VALUE)) { + const noneSymptom = catalog.find( + symptom => this.normalize(symptom.name) === NO_SYMPTOM_VALUE, ); + return noneSymptom ? [noneSymptom.id] : []; } - const ids: string[] = []; + return selectedNames + .map(selectedName => { + const normalizedName = this.normalize(selectedName); - for (const slug of selectedSlugs) { - const normalizedSlug = this.normalize(slug); - const match = catalog.find(symptom => this.normalize(symptom.name) === normalizedSlug); + return catalog.find( + symptom => this.normalize(symptom.name) === normalizedName, + )?.id; + }) + .filter((id): id is string => !!id); + } - if (match) { - ids.push(match.id); - } - } + buildSymptomOptions(catalog: SymptomResponse[]): SymptomOption[] { + return catalog.map(symptom => { + const normalizedName = this.normalize(symptom.name); + const style = this.symptomStyleMap[normalizedName] ?? { + selectedClass: 'bg-[#E9E3FF] border-[#CFC2FF] text-[#4B3B8F]', + unselectedClass: 'bg-[#F5F2FF] border-[#DDD3F8] text-[#44403C]', + }; - return ids; + return { + id: symptom.id, + value: symptom.name, + label: symptom.name === 'Nenhum sintoma' ? 'Nenhum sintoma hoje' : symptom.name, + category: symptom.category, + description: symptom.description, + selectedClass: style.selectedClass, + unselectedClass: style.unselectedClass, + }; + }); } private normalize(value: string): string { @@ -52,4 +147,4 @@ export class CheckinService { .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' '); } -} +} \ No newline at end of file diff --git a/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts index bb9f70b..26da619 100644 --- a/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts +++ b/frontend/src/app/features/education/education-article-page/education-article-page.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { ActivatedRoute, provideRouter, Router, convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; +import { environment } from '../../../../environments/environment'; import { EducationArticlePage } from './education-article-page'; import { Education } from '../education'; import type { Article } from '../models/article.models'; @@ -54,7 +55,7 @@ describe('EducationArticlePage', () => { fixture = TestBed.createComponent(EducationArticlePage); fixture.detectChanges(); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-diarios'); + const req = httpMock.expectOne(`${environment.apiUrl}/v1/articles/cuidados-diarios`); req.flush(mockArticle); fixture.detectChanges(); }); diff --git a/frontend/src/app/features/education/education.spec.ts b/frontend/src/app/features/education/education.spec.ts index 7af07aa..d200f70 100644 --- a/frontend/src/app/features/education/education.spec.ts +++ b/frontend/src/app/features/education/education.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { provideRouter, Router } from '@angular/router'; import { By } from '@angular/platform-browser'; +import { environment } from '../../../environments/environment'; import { Education } from './education'; import { EducationArticlePage } from './education-article-page/education-article-page'; import type { Article, ArticleListResponse } from './models/article.models'; @@ -47,20 +48,21 @@ const mockArticles: Article[] = [ ]; describe('Education', () => { + const baseUrl = `${environment.apiUrl}/v1/articles`; let component: Education; let fixture: ComponentFixture; let httpMock: HttpTestingController; let router: Router; function flushInitialRequests(list: ArticleListResponse = { items: mockArticles, total: 2 }): void { - const tagsReq = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + const tagsReq = httpMock.expectOne(`${baseUrl}/tags`); tagsReq.flush([ { id: 't1', name: 'cuidados' }, { id: 't2', name: 'tratamento' }, ]); const listReq = httpMock.expectOne( - (r) => r.url === 'http://localhost:8000/v1/articles' && r.params.get('category') === 'education' + (r) => r.url === baseUrl && r.params.get('category') === 'education' ); listReq.flush(list); } @@ -117,7 +119,7 @@ describe('Education', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('tag') === 'cuidados' && r.params.get('category') === 'education' ); @@ -135,7 +137,7 @@ describe('Education', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('search') === 'adesão' ); req.flush({ items: [mockArticles[1]], total: 1 }); diff --git a/frontend/src/app/features/education/services/articles.service.spec.ts b/frontend/src/app/features/education/services/articles.service.spec.ts index 70dc3f3..ffcd898 100644 --- a/frontend/src/app/features/education/services/articles.service.spec.ts +++ b/frontend/src/app/features/education/services/articles.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { environment } from '../../../../environments/environment'; import { ArticlesService } from './articles.service'; import type { Article, ArticleListResponse } from '../models/article.models'; @@ -24,6 +25,7 @@ const mockArticle: Article = { }; describe('ArticlesService', () => { + const baseUrl = `${environment.apiUrl}/v1/articles`; let service: ArticlesService; let httpMock: HttpTestingController; @@ -48,7 +50,7 @@ describe('ArticlesService', () => { const req = httpMock.expectOne( (r) => - r.url === 'http://localhost:8000/v1/articles' && + r.url === baseUrl && r.params.get('category') === 'education' && r.params.get('tag') === 'cuidados' && r.params.get('search') === 'pele' && @@ -60,7 +62,7 @@ describe('ArticlesService', () => { it('should get article by slug', () => { service.getArticle('cuidados-com-a-pele').subscribe((data) => expect(data).toEqual(mockArticle)); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/cuidados-com-a-pele'); + const req = httpMock.expectOne(`${baseUrl}/cuidados-com-a-pele`); req.flush(mockArticle); }); @@ -69,7 +71,7 @@ describe('ArticlesService', () => { service.listTags().subscribe((data) => expect(data).toEqual(tags)); - const req = httpMock.expectOne('http://localhost:8000/v1/articles/tags'); + const req = httpMock.expectOne(`${baseUrl}/tags`); req.flush(tags); }); }); diff --git a/frontend/src/app/features/education/services/articles.service.ts b/frontend/src/app/features/education/services/articles.service.ts index 57bcf63..23e3cd9 100644 --- a/frontend/src/app/features/education/services/articles.service.ts +++ b/frontend/src/app/features/education/services/articles.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; import type { Article, ArticleListResponse, @@ -11,7 +12,7 @@ import type { @Injectable({ providedIn: 'root' }) export class ArticlesService { private readonly http = inject(HttpClient); - private readonly baseUrl = 'http://localhost:8000/v1/articles'; + private readonly baseUrl = `${environment.apiUrl}/v1/articles`; listArticles(params: ListArticlesParams = {}): Observable { let httpParams = new HttpParams();