From 625275e55bbe3ee079a57ec625a1a03ff3594da7 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Mon, 27 Jul 2026 11:38:31 +0330 Subject: [PATCH] feat(healthcare): add backend service and finalize frontend build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds. Co-authored-by: Cursor --- backend/services/healthcare/Dockerfile.dev | 22 + backend/services/healthcare/README.md | 64 ++ backend/services/healthcare/alembic.ini | 41 + backend/services/healthcare/alembic/env.py | 42 + .../alembic/versions/0001_initial.py | 19 + .../versions/0002_phase_131_appointments.py | 26 + .../versions/0003_phase_132_doctor_panel.py | 19 + .../0004_phase_133_clinic_management.py | 24 + .../versions/0005_phase_134_patient_portal.py | 23 + .../versions/0006_phase_135_medical_record.py | 27 + .../0007_phase_136_pharmacy_network.py | 24 + .../0008_phase_137_delivery_integration.py | 23 + backend/services/healthcare/app/__init__.py | 1 + backend/services/healthcare/app/api/deps.py | 39 + .../healthcare/app/api/patient_deps.py | 22 + .../healthcare/app/api/permissions.py | 48 ++ .../healthcare/app/api/v1/__init__.py | 101 +++ .../healthcare/app/api/v1/appointments.py | 233 ++++++ .../services/healthcare/app/api/v1/audit.py | 30 + .../healthcare/app/api/v1/branches.py | 75 ++ .../app/api/v1/clinic_management.py | 119 +++ .../services/healthcare/app/api/v1/clinics.py | 75 ++ .../healthcare/app/api/v1/configurations.py | 83 ++ .../app/api/v1/delivery_integration.py | 90 ++ .../healthcare/app/api/v1/departments.py | 75 ++ .../healthcare/app/api/v1/doctor_panel.py | 183 ++++ .../services/healthcare/app/api/v1/doctors.py | 75 ++ .../app/api/v1/external_providers.py | 83 ++ .../services/healthcare/app/api/v1/health.py | 90 ++ .../healthcare/app/api/v1/medical_records.py | 151 ++++ .../healthcare/app/api/v1/patient_portal.py | 128 +++ .../healthcare/app/api/v1/patients.py | 75 ++ .../healthcare/app/api/v1/permissions.py | 27 + .../healthcare/app/api/v1/pharmacy.py | 138 +++ .../healthcare/app/api/v1/settings.py | 39 + .../healthcare/app/commands/__init__.py | 4 + .../healthcare/app/commands/appointments.py | 72 ++ .../app/commands/clinic_management.py | 51 ++ .../healthcare/app/commands/doctor_panel.py | 81 ++ .../healthcare/app/commands/profiles.py | 46 + .../services/healthcare/app/core/config.py | 75 ++ .../services/healthcare/app/core/database.py | 27 + .../services/healthcare/app/core/logging.py | 14 + .../services/healthcare/app/core/security.py | 49 ++ .../healthcare/app/events/publisher.py | 121 +++ .../services/healthcare/app/events/types.py | 63 ++ backend/services/healthcare/app/main.py | 139 ++++ .../healthcare/app/middlewares/tenant.py | 24 + .../healthcare/app/models/__init__.py | 53 ++ .../healthcare/app/models/appointments.py | 164 ++++ .../services/healthcare/app/models/base.py | 51 ++ .../app/models/clinic_management.py | 126 +++ .../app/models/delivery_integration.py | 100 +++ .../healthcare/app/models/doctor_panel.py | 76 ++ .../healthcare/app/models/foundation.py | 296 +++++++ .../healthcare/app/models/medical_records.py | 178 ++++ .../services/healthcare/app/models/outbox.py | 44 + .../healthcare/app/models/patient_portal.py | 94 +++ .../healthcare/app/models/pharmacy.py | 124 +++ .../healthcare/app/models/profiles.py | 85 ++ .../services/healthcare/app/models/types.py | 175 ++++ .../healthcare/app/permissions/definitions.py | 183 ++++ .../healthcare/app/policies/__init__.py | 4 + .../healthcare/app/policies/appointments.py | 13 + .../healthcare/app/policies/doctor_panel.py | 13 + .../app/policies/medical_records.py | 25 + .../healthcare/app/policies/pharmacy.py | 13 + .../healthcare/app/providers/__init__.py | 11 + .../healthcare/app/providers/contracts.py | 75 ++ .../healthcare/app/providers/mocks.py | 89 ++ .../healthcare/app/queries/__init__.py | 4 + .../healthcare/app/queries/appointments.py | 49 ++ .../healthcare/app/queries/doctor_panel.py | 37 + .../healthcare/app/queries/profiles.py | 26 + .../app/repositories/appointments.py | 153 ++++ .../healthcare/app/repositories/base.py | 78 ++ .../app/repositories/clinic_management.py | 62 ++ .../app/repositories/delivery_integration.py | 65 ++ .../app/repositories/doctor_panel.py | 119 +++ .../healthcare/app/repositories/foundation.py | 150 ++++ .../app/repositories/medical_records.py | 116 +++ .../app/repositories/patient_portal.py | 74 ++ .../healthcare/app/repositories/pharmacy.py | 52 ++ .../healthcare/app/repositories/profiles.py | 53 ++ .../healthcare/app/schemas/appointments.py | 182 ++++ .../app/schemas/clinic_management.py | 126 +++ .../services/healthcare/app/schemas/common.py | 18 + .../app/schemas/delivery_integration.py | 77 ++ .../healthcare/app/schemas/doctor_panel.py | 98 +++ .../healthcare/app/schemas/foundation.py | 284 +++++++ .../healthcare/app/schemas/medical_records.py | 168 ++++ .../healthcare/app/schemas/patient_portal.py | 99 +++ .../healthcare/app/schemas/pharmacy.py | 102 +++ .../healthcare/app/schemas/profiles.py | 109 +++ .../healthcare/app/services/appointments.py | 485 +++++++++++ .../healthcare/app/services/audit_service.py | 51 ++ .../app/services/clinic_management.py | 215 +++++ .../app/services/delivery_integration.py | 199 +++++ .../healthcare/app/services/doctor_panel.py | 390 +++++++++ .../healthcare/app/services/foundation.py | 786 ++++++++++++++++++ .../app/services/medical_records.py | 334 ++++++++ .../healthcare/app/services/patient_portal.py | 223 +++++ .../healthcare/app/services/pharmacy.py | 215 +++++ .../healthcare/app/services/profiles.py | 320 +++++++ .../healthcare/app/specifications/__init__.py | 4 + .../app/specifications/appointments.py | 25 + .../app/specifications/doctor_panel.py | 18 + .../services/healthcare/app/tests/conftest.py | 66 ++ .../services/healthcare/app/tests/test_api.py | 165 ++++ .../healthcare/app/tests/test_architecture.py | 177 ++++ .../healthcare/app/tests/test_dependency.py | 34 + .../healthcare/app/tests/test_docs.py | 32 + .../healthcare/app/tests/test_migration.py | 60 ++ .../healthcare/app/tests/test_permissions.py | 29 + .../healthcare/app/tests/test_phase_131.py | 209 +++++ .../healthcare/app/tests/test_phase_132.py | 210 +++++ .../healthcare/app/tests/test_phase_133.py | 106 +++ .../healthcare/app/tests/test_phase_134.py | 132 +++ .../healthcare/app/tests/test_phase_135.py | 124 +++ .../healthcare/app/tests/test_phase_136.py | 110 +++ .../healthcare/app/tests/test_phase_137.py | 119 +++ .../healthcare/app/tests/test_security.py | 40 + .../healthcare/app/tests/test_tenant.py | 65 ++ .../healthcare/app/validators/__init__.py | 14 + .../healthcare/app/validators/appointments.py | 87 ++ .../app/validators/clinic_management.py | 26 + .../healthcare/app/validators/doctor_panel.py | 42 + .../healthcare/app/validators/foundation.py | 55 ++ .../app/validators/medical_records.py | 29 + .../healthcare/app/validators/pharmacy.py | 46 + backend/services/healthcare/pytest.ini | 8 + backend/services/healthcare/requirements.txt | 14 + .../scripts/app/models/doctor_panel.py | 43 + .../services/healthcare/scripts/ensure_db.py | 41 + .../services/healthcare/scripts/gen_phases.py | 74 ++ .../healthcare/scripts/implement_phases.py | 292 +++++++ .../docs/healthcare-api-connection-report.md | 118 +++ .../docs/healthcare-ui-completion-report.md | 92 ++ frontend/docs/healthcare-validation-report.md | 108 +++ .../loyalty/features/membership/members.tsx | 2 + .../loyalty/features/membership/programs.tsx | 2 + .../loyalty/features/membership/tiers.tsx | 2 + .../loyalty/features/points/accounts.tsx | 2 + .../loyalty/features/referrals/programs.tsx | 2 + .../loyalty/features/rewards/rewards.tsx | 2 + frontend/src/modules/healthcare/index.ts | 1 + frontend/src/shared/index.ts | 1 + frontend/src/shared/ui/index.ts | 4 + scripts/deploy_healthcare_prod.py | 336 ++++++++ 149 files changed, 13779 insertions(+) create mode 100644 backend/services/healthcare/Dockerfile.dev create mode 100644 backend/services/healthcare/README.md create mode 100644 backend/services/healthcare/alembic.ini create mode 100644 backend/services/healthcare/alembic/env.py create mode 100644 backend/services/healthcare/alembic/versions/0001_initial.py create mode 100644 backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py create mode 100644 backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py create mode 100644 backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py create mode 100644 backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py create mode 100644 backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py create mode 100644 backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py create mode 100644 backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py create mode 100644 backend/services/healthcare/app/__init__.py create mode 100644 backend/services/healthcare/app/api/deps.py create mode 100644 backend/services/healthcare/app/api/patient_deps.py create mode 100644 backend/services/healthcare/app/api/permissions.py create mode 100644 backend/services/healthcare/app/api/v1/__init__.py create mode 100644 backend/services/healthcare/app/api/v1/appointments.py create mode 100644 backend/services/healthcare/app/api/v1/audit.py create mode 100644 backend/services/healthcare/app/api/v1/branches.py create mode 100644 backend/services/healthcare/app/api/v1/clinic_management.py create mode 100644 backend/services/healthcare/app/api/v1/clinics.py create mode 100644 backend/services/healthcare/app/api/v1/configurations.py create mode 100644 backend/services/healthcare/app/api/v1/delivery_integration.py create mode 100644 backend/services/healthcare/app/api/v1/departments.py create mode 100644 backend/services/healthcare/app/api/v1/doctor_panel.py create mode 100644 backend/services/healthcare/app/api/v1/doctors.py create mode 100644 backend/services/healthcare/app/api/v1/external_providers.py create mode 100644 backend/services/healthcare/app/api/v1/health.py create mode 100644 backend/services/healthcare/app/api/v1/medical_records.py create mode 100644 backend/services/healthcare/app/api/v1/patient_portal.py create mode 100644 backend/services/healthcare/app/api/v1/patients.py create mode 100644 backend/services/healthcare/app/api/v1/permissions.py create mode 100644 backend/services/healthcare/app/api/v1/pharmacy.py create mode 100644 backend/services/healthcare/app/api/v1/settings.py create mode 100644 backend/services/healthcare/app/commands/__init__.py create mode 100644 backend/services/healthcare/app/commands/appointments.py create mode 100644 backend/services/healthcare/app/commands/clinic_management.py create mode 100644 backend/services/healthcare/app/commands/doctor_panel.py create mode 100644 backend/services/healthcare/app/commands/profiles.py create mode 100644 backend/services/healthcare/app/core/config.py create mode 100644 backend/services/healthcare/app/core/database.py create mode 100644 backend/services/healthcare/app/core/logging.py create mode 100644 backend/services/healthcare/app/core/security.py create mode 100644 backend/services/healthcare/app/events/publisher.py create mode 100644 backend/services/healthcare/app/events/types.py create mode 100644 backend/services/healthcare/app/main.py create mode 100644 backend/services/healthcare/app/middlewares/tenant.py create mode 100644 backend/services/healthcare/app/models/__init__.py create mode 100644 backend/services/healthcare/app/models/appointments.py create mode 100644 backend/services/healthcare/app/models/base.py create mode 100644 backend/services/healthcare/app/models/clinic_management.py create mode 100644 backend/services/healthcare/app/models/delivery_integration.py create mode 100644 backend/services/healthcare/app/models/doctor_panel.py create mode 100644 backend/services/healthcare/app/models/foundation.py create mode 100644 backend/services/healthcare/app/models/medical_records.py create mode 100644 backend/services/healthcare/app/models/outbox.py create mode 100644 backend/services/healthcare/app/models/patient_portal.py create mode 100644 backend/services/healthcare/app/models/pharmacy.py create mode 100644 backend/services/healthcare/app/models/profiles.py create mode 100644 backend/services/healthcare/app/models/types.py create mode 100644 backend/services/healthcare/app/permissions/definitions.py create mode 100644 backend/services/healthcare/app/policies/__init__.py create mode 100644 backend/services/healthcare/app/policies/appointments.py create mode 100644 backend/services/healthcare/app/policies/doctor_panel.py create mode 100644 backend/services/healthcare/app/policies/medical_records.py create mode 100644 backend/services/healthcare/app/policies/pharmacy.py create mode 100644 backend/services/healthcare/app/providers/__init__.py create mode 100644 backend/services/healthcare/app/providers/contracts.py create mode 100644 backend/services/healthcare/app/providers/mocks.py create mode 100644 backend/services/healthcare/app/queries/__init__.py create mode 100644 backend/services/healthcare/app/queries/appointments.py create mode 100644 backend/services/healthcare/app/queries/doctor_panel.py create mode 100644 backend/services/healthcare/app/queries/profiles.py create mode 100644 backend/services/healthcare/app/repositories/appointments.py create mode 100644 backend/services/healthcare/app/repositories/base.py create mode 100644 backend/services/healthcare/app/repositories/clinic_management.py create mode 100644 backend/services/healthcare/app/repositories/delivery_integration.py create mode 100644 backend/services/healthcare/app/repositories/doctor_panel.py create mode 100644 backend/services/healthcare/app/repositories/foundation.py create mode 100644 backend/services/healthcare/app/repositories/medical_records.py create mode 100644 backend/services/healthcare/app/repositories/patient_portal.py create mode 100644 backend/services/healthcare/app/repositories/pharmacy.py create mode 100644 backend/services/healthcare/app/repositories/profiles.py create mode 100644 backend/services/healthcare/app/schemas/appointments.py create mode 100644 backend/services/healthcare/app/schemas/clinic_management.py create mode 100644 backend/services/healthcare/app/schemas/common.py create mode 100644 backend/services/healthcare/app/schemas/delivery_integration.py create mode 100644 backend/services/healthcare/app/schemas/doctor_panel.py create mode 100644 backend/services/healthcare/app/schemas/foundation.py create mode 100644 backend/services/healthcare/app/schemas/medical_records.py create mode 100644 backend/services/healthcare/app/schemas/patient_portal.py create mode 100644 backend/services/healthcare/app/schemas/pharmacy.py create mode 100644 backend/services/healthcare/app/schemas/profiles.py create mode 100644 backend/services/healthcare/app/services/appointments.py create mode 100644 backend/services/healthcare/app/services/audit_service.py create mode 100644 backend/services/healthcare/app/services/clinic_management.py create mode 100644 backend/services/healthcare/app/services/delivery_integration.py create mode 100644 backend/services/healthcare/app/services/doctor_panel.py create mode 100644 backend/services/healthcare/app/services/foundation.py create mode 100644 backend/services/healthcare/app/services/medical_records.py create mode 100644 backend/services/healthcare/app/services/patient_portal.py create mode 100644 backend/services/healthcare/app/services/pharmacy.py create mode 100644 backend/services/healthcare/app/services/profiles.py create mode 100644 backend/services/healthcare/app/specifications/__init__.py create mode 100644 backend/services/healthcare/app/specifications/appointments.py create mode 100644 backend/services/healthcare/app/specifications/doctor_panel.py create mode 100644 backend/services/healthcare/app/tests/conftest.py create mode 100644 backend/services/healthcare/app/tests/test_api.py create mode 100644 backend/services/healthcare/app/tests/test_architecture.py create mode 100644 backend/services/healthcare/app/tests/test_dependency.py create mode 100644 backend/services/healthcare/app/tests/test_docs.py create mode 100644 backend/services/healthcare/app/tests/test_migration.py create mode 100644 backend/services/healthcare/app/tests/test_permissions.py create mode 100644 backend/services/healthcare/app/tests/test_phase_131.py create mode 100644 backend/services/healthcare/app/tests/test_phase_132.py create mode 100644 backend/services/healthcare/app/tests/test_phase_133.py create mode 100644 backend/services/healthcare/app/tests/test_phase_134.py create mode 100644 backend/services/healthcare/app/tests/test_phase_135.py create mode 100644 backend/services/healthcare/app/tests/test_phase_136.py create mode 100644 backend/services/healthcare/app/tests/test_phase_137.py create mode 100644 backend/services/healthcare/app/tests/test_security.py create mode 100644 backend/services/healthcare/app/tests/test_tenant.py create mode 100644 backend/services/healthcare/app/validators/__init__.py create mode 100644 backend/services/healthcare/app/validators/appointments.py create mode 100644 backend/services/healthcare/app/validators/clinic_management.py create mode 100644 backend/services/healthcare/app/validators/doctor_panel.py create mode 100644 backend/services/healthcare/app/validators/foundation.py create mode 100644 backend/services/healthcare/app/validators/medical_records.py create mode 100644 backend/services/healthcare/app/validators/pharmacy.py create mode 100644 backend/services/healthcare/pytest.ini create mode 100644 backend/services/healthcare/requirements.txt create mode 100644 backend/services/healthcare/scripts/app/models/doctor_panel.py create mode 100644 backend/services/healthcare/scripts/ensure_db.py create mode 100644 backend/services/healthcare/scripts/gen_phases.py create mode 100644 backend/services/healthcare/scripts/implement_phases.py create mode 100644 frontend/docs/healthcare-api-connection-report.md create mode 100644 frontend/docs/healthcare-ui-completion-report.md create mode 100644 frontend/docs/healthcare-validation-report.md create mode 100644 scripts/deploy_healthcare_prod.py diff --git a/backend/services/healthcare/Dockerfile.dev b/backend/services/healthcare/Dockerfile.dev new file mode 100644 index 0000000..8faa773 --- /dev/null +++ b/backend/services/healthcare/Dockerfile.dev @@ -0,0 +1,22 @@ +# Dev image: dependencies only — code mounted with uvicorn --reload +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/shared-lib/ /shared-lib/ +COPY backend/services/healthcare/requirements.txt /app/requirements.txt + +RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \ + && pip install --upgrade pip \ + && pip install -r requirements.txt + +EXPOSE 8010 diff --git a/backend/services/healthcare/README.md b/backend/services/healthcare/README.md new file mode 100644 index 0000000..64f8efc --- /dev/null +++ b/backend/services/healthcare/README.md @@ -0,0 +1,64 @@ +# Healthcare Platform (Torbat Healthcare) + +| Field | Value | +| --- | --- | +| Service | `healthcare-service` | +| Database | `healthcare_db` | +| Port | 8010 | +| Permission prefix | `healthcare.*` | +| Version | 0.13.0.0 | +| Phase | 13.0 Healthcare Foundation | + +## Owns + +- Clinics, branches, departments, configuration/settings shells +- Doctor and patient profile shells (Identity user refs only) +- External provider **registrations** (contracts only) +- Healthcare roles/permissions catalog shells +- Healthcare audit log + transactional outbox +- Health / capabilities / metrics discovery +- Publish-only `healthcare.*` events + +## Does not own + +- Accounting journals / billing posting +- Communication SMS/email providers +- Loyalty ledger +- Identity user administration +- Appointment booking, visit workflows, medical records, pharmacy (phases 13.1–13.7) +- Delivery dispatch engines +- Frontend UI + +## APIs + +| Method | Path | +| --- | --- | +| GET | `/health` | +| GET | `/capabilities` | +| GET | `/metrics` | +| CRUD | `/api/v1/clinics` | +| CRUD | `/api/v1/branches` | +| CRUD | `/api/v1/departments` | +| CRUD | `/api/v1/doctors` | +| CRUD | `/api/v1/patients` | +| CRUD | `/api/v1/external-providers` | +| CRUD | `/api/v1/configurations` | +| PUT/GET | `/api/v1/settings` | +| GET | `/api/v1/audit` | +| GET | `/api/v1/permissions/catalog` | + +## Run (dev) + +```bash +export HEALTHCARE_DATABASE_URL=postgresql+asyncpg://... +export HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://... +python scripts/ensure_db.py +alembic upgrade head +uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload +``` + +## Tests + +```bash +pytest +``` diff --git a/backend/services/healthcare/alembic.ini b/backend/services/healthcare/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/healthcare/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/healthcare/alembic/env.py b/backend/services/healthcare/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/healthcare/alembic/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url_sync) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url_sync, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/healthcare/alembic/versions/0001_initial.py b/backend/services/healthcare/alembic/versions/0001_initial.py new file mode 100644 index 0000000..2054169 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Delivery schema — Phase 13.0 foundation.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + Base.metadata.drop_all(bind=bind) diff --git a/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py b/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py new file mode 100644 index 0000000..c1b2fc6 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py @@ -0,0 +1,26 @@ +"""Phase 13.1 — Appointment Engine.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0002_phase_131_appointments" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "waiting_list_entries", + "appointments", + "appointment_types", + "availability_windows", + "schedules", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py b/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py new file mode 100644 index 0000000..3aebb20 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py @@ -0,0 +1,19 @@ +"""Phase 13.2 — Doctor Panel.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0003_phase_132_doctor_panel" +down_revision = "0002_phase_131_appointments" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ("visit_notes", "visit_sessions"): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py b/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py new file mode 100644 index 0000000..d3f2dee --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py @@ -0,0 +1,24 @@ +"""Phase 13.3 — Clinic Management.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0004_phase_133_clinic_management" +down_revision = "0003_phase_132_doctor_panel" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "operating_hours_policies", + "clinic_policies", + "staff_assignments", + "clinic_services", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py b/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py new file mode 100644 index 0000000..3eeb283 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py @@ -0,0 +1,23 @@ +"""Phase 13.4 — Patient Portal.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0005_phase_134_patient_portal" +down_revision = "0004_phase_133_clinic_management" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "patient_notification_preferences", + "patient_document_refs", + "patient_portal_profiles", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py b/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py new file mode 100644 index 0000000..9bf5a4b --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py @@ -0,0 +1,27 @@ +"""Phase 13.5 — Medical Record.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0006_phase_135_medical_record" +down_revision = "0005_phase_134_patient_portal" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "medical_record_access_logs", + "immunization_records", + "medication_statements", + "allergies", + "conditions", + "encounters", + "medical_records", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py b/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py new file mode 100644 index 0000000..ce4b7bb --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py @@ -0,0 +1,24 @@ +"""Phase 13.6 — Pharmacy Network.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0007_phase_136_pharmacy_network" +down_revision = "0006_phase_135_medical_record" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "prescription_status_history", + "prescription_lines", + "prescription_orders", + "pharmacies", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py b/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py new file mode 100644 index 0000000..c1aa37b --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py @@ -0,0 +1,23 @@ +"""Phase 13.7 — Delivery Integration.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0008_phase_137_delivery" +down_revision = "0007_phase_136_pharmacy_network" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "delivery_job_status_snapshots", + "delivery_job_intents", + "delivery_integration_registrations", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/app/__init__.py b/backend/services/healthcare/app/__init__.py new file mode 100644 index 0000000..558b479 --- /dev/null +++ b/backend/services/healthcare/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.13.7.0" diff --git a/backend/services/healthcare/app/api/deps.py b/backend/services/healthcare/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/healthcare/app/api/deps.py @@ -0,0 +1,39 @@ +"""Common API dependencies.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from shared.exceptions import TenantNotResolvedError +from shared.pagination import PaginationParams +from shared.security import CurrentUser +from shared.tenant import STATE_TENANT_ID + +__all__ = [ + "get_db", + "get_pagination", + "require_tenant", + "get_current_user", + "AsyncSession", + "CurrentUser", +] + + +def get_pagination( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500), +) -> PaginationParams: + return PaginationParams(page=page, page_size=page_size) + + +def require_tenant(request: Request) -> UUID: + tenant_id = getattr(request.state, STATE_TENANT_ID, None) + if tenant_id is None: + raise TenantNotResolvedError( + "tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید." + ) + return tenant_id diff --git a/backend/services/healthcare/app/api/patient_deps.py b/backend/services/healthcare/app/api/patient_deps.py new file mode 100644 index 0000000..bf5163a --- /dev/null +++ b/backend/services/healthcare/app/api/patient_deps.py @@ -0,0 +1,22 @@ +"""Patient-scoped API dependencies — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Header +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.repositories.profiles import PatientRepository +from shared.exceptions import NotFoundError + + +async def require_patient_context( + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + x_patient_id: UUID = Header(..., alias="X-Patient-ID"), +) -> UUID: + repo = PatientRepository(db) + if await repo.get(tenant_id, x_patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + return x_patient_id diff --git a/backend/services/healthcare/app/api/permissions.py b/backend/services/healthcare/app/api/permissions.py new file mode 100644 index 0000000..0484920 --- /dev/null +++ b/backend/services/healthcare/app/api/permissions.py @@ -0,0 +1,48 @@ +"""Permission enforcement helpers for Delivery APIs.""" +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends + +from app.core.config import settings +from app.core.security import get_current_user +from shared.exceptions import ForbiddenError +from shared.security import CurrentUser + +_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"}) + + +def user_has_permission(user: CurrentUser, permission: str) -> bool: + if any(role in _ADMIN_ROLES for role in user.roles): + return True + roles = set(user.roles) + if "healthcare.manage" in roles or permission in roles: + return True + if "healthcare.view" in roles and permission.endswith(".view"): + return True + parts = permission.split(".") + if len(parts) >= 3: + manage = f"{parts[0]}.{parts[1]}.manage" + if manage in roles: + return True + return False + + +def require_permissions(*permissions: str) -> Callable: + """Deny unless AUTH is off, user is admin, or any listed permission is present.""" + + async def _dependency( + user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if not settings.auth_required: + return user + if any(user_has_permission(user, perm) for perm in permissions): + return user + raise ForbiddenError( + "دسترسی مجاز نیست", + error_code="permission_denied", + details={"required": list(permissions)}, + ) + + return _dependency diff --git a/backend/services/healthcare/app/api/v1/__init__.py b/backend/services/healthcare/app/api/v1/__init__.py new file mode 100644 index 0000000..a63559e --- /dev/null +++ b/backend/services/healthcare/app/api/v1/__init__.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + appointments, + audit, + branches, + clinic_management, + clinics, + configurations, + delivery_integration, + departments, + doctor_panel, + doctors, + external_providers, + medical_records, + patient_portal, + patients, + permissions, + pharmacy, + settings, +) + +api_router = APIRouter() +api_router.include_router(clinics.router, prefix="/clinics", tags=["clinics"]) +api_router.include_router(branches.router, prefix="/branches", tags=["branches"]) +api_router.include_router(departments.router, prefix="/departments", tags=["departments"]) +api_router.include_router(doctors.router, prefix="/doctors", tags=["doctors"]) +api_router.include_router(patients.router, prefix="/patients", tags=["patients"]) +api_router.include_router( + appointments.schedules_router, prefix="/schedules", tags=["schedules"] +) +api_router.include_router( + appointments.availability_router, prefix="/availability-windows", tags=["availability"] +) +api_router.include_router( + appointments.appointment_types_router, prefix="/appointment-types", tags=["appointment-types"] +) +api_router.include_router( + appointments.appointments_router, prefix="/appointments", tags=["appointments"] +) +api_router.include_router( + appointments.waiting_list_router, prefix="/waiting-list", tags=["waiting-list"] +) +api_router.include_router( + doctor_panel.doctor_panel_router, prefix="/doctor-panel", tags=["doctor-panel"] +) +api_router.include_router( + doctor_panel.visit_sessions_router, prefix="/visit-sessions", tags=["visit-sessions"] +) +api_router.include_router( + doctor_panel.visit_notes_router, prefix="/visit-notes", tags=["visit-notes"] +) +api_router.include_router( + clinic_management.services_router, prefix="/clinic-services", tags=["clinic-services"] +) +api_router.include_router( + clinic_management.staff_router, prefix="/staff-assignments", tags=["staff-assignments"] +) +api_router.include_router( + clinic_management.policies_router, prefix="/clinic-policies", tags=["clinic-policies"] +) +api_router.include_router( + clinic_management.hours_router, prefix="/operating-hours-policies", tags=["operating-hours"] +) +api_router.include_router( + patient_portal.router, prefix="/patient-portal", tags=["patient-portal"] +) +api_router.include_router( + medical_records.records_router, prefix="/medical-records", tags=["medical-records"] +) +api_router.include_router( + medical_records.encounters_router, prefix="/encounters", tags=["encounters"] +) +api_router.include_router(pharmacy.pharmacies_router, prefix="/pharmacies", tags=["pharmacies"]) +api_router.include_router( + pharmacy.prescription_orders_router, prefix="/prescription-orders", tags=["prescription-orders"] +) +api_router.include_router( + delivery_integration.integrations_router, + prefix="/delivery-integrations", + tags=["delivery-integrations"], +) +api_router.include_router( + delivery_integration.intents_router, prefix="/delivery-job-intents", tags=["delivery-job-intents"] +) +api_router.include_router( + delivery_integration.webhooks_router, prefix="/webhooks", tags=["webhooks"] +) +api_router.include_router( + external_providers.router, + prefix="/external-providers", + tags=["external-providers"], +) +api_router.include_router( + configurations.router, prefix="/configurations", tags=["configurations"] +) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) +api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) +api_router.include_router( + permissions.router, prefix="/permissions", tags=["permissions"] +) diff --git a/backend/services/healthcare/app/api/v1/appointments.py b/backend/services/healthcare/app/api/v1/appointments.py new file mode 100644 index 0000000..a2baecc --- /dev/null +++ b/backend/services/healthcare/app/api/v1/appointments.py @@ -0,0 +1,233 @@ +"""Appointment scheduling APIs — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.appointments import AppointmentCommands +from app.models.types import AppointmentStatus +from app.permissions.definitions import APPOINTMENTS_MANAGE, APPOINTMENTS_VIEW, SCHEDULES_MANAGE, SCHEDULES_VIEW +from app.queries.appointments import AppointmentQueries +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentListResponse, + AppointmentRead, + AppointmentReschedule, + AppointmentTypeCreate, + AppointmentTypeRead, + AvailabilityWindowCreate, + AvailabilityWindowRead, + ScheduleCreate, + ScheduleRead, + WaitingListEntryCreate, + WaitingListEntryRead, +) +from app.specifications.appointments import ALLOWED_SORT_FIELDS, AppointmentListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +schedules_router = APIRouter() +availability_router = APIRouter() +appointment_types_router = APIRouter() +appointments_router = APIRouter() +waiting_list_router = APIRouter() + + +def _appointment_spec( + status_filter: AppointmentStatus | None = Query(default=None, alias="status"), + doctor_id: UUID | None = Query(default=None), + clinic_id: UUID | None = Query(default=None), + patient_id: UUID | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="scheduled_start"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> AppointmentListSpec: + if sort_by not in ALLOWED_SORT_FIELDS: + sort_by = "scheduled_start" + return AppointmentListSpec( + status=status_filter, + doctor_id=doctor_id, + clinic_id=clinic_id, + patient_id=patient_id, + date_from=date_from, + date_to=date_to, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@schedules_router.post("", response_model=ScheduleRead, status_code=status.HTTP_201_CREATED) +async def create_schedule( + body: ScheduleCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SCHEDULES_MANAGE)), +): + return await AppointmentCommands(db).create_schedule(tenant_id, body, actor=user) + + +@schedules_router.get("", response_model=list[ScheduleRead]) +async def list_schedules( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW)), +): + return await AppointmentQueries(db).list_schedules( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@availability_router.post("", response_model=AvailabilityWindowRead, status_code=status.HTTP_201_CREATED) +async def create_availability( + body: AvailabilityWindowCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SCHEDULES_MANAGE)), +): + return await AppointmentCommands(db).create_availability(tenant_id, body, actor=user) + + +@availability_router.get("", response_model=list[AvailabilityWindowRead]) +async def list_availability( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW)), +): + return await AppointmentQueries(db).list_availability( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@appointment_types_router.post("", response_model=AppointmentTypeRead, status_code=status.HTTP_201_CREATED) +async def create_appointment_type( + body: AppointmentTypeCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).create_type(tenant_id, body, actor=user) + + +@appointment_types_router.get("", response_model=list[AppointmentTypeRead]) +async def list_appointment_types( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).list_types( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@appointments_router.post("", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED) +async def book_appointment( + body: AppointmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).book(tenant_id, body, actor=user) + + +@appointments_router.get("", response_model=AppointmentListResponse) +async def list_appointments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AppointmentListSpec = Depends(_appointment_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + items, total = await AppointmentQueries(db).list_appointments( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AppointmentListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@appointments_router.get("/{appointment_id}", response_model=AppointmentRead) +async def get_appointment( + appointment_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).get_appointment(tenant_id, appointment_id) + + +@appointments_router.post("/{appointment_id}/confirm", response_model=AppointmentRead) +async def confirm_appointment( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).confirm(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentRead) +async def cancel_appointment( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).cancel(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/reschedule", response_model=AppointmentRead) +async def reschedule_appointment( + appointment_id: UUID, + body: AppointmentReschedule, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).reschedule(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/no-show", response_model=AppointmentRead) +async def mark_no_show( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).mark_no_show(tenant_id, appointment_id, body, actor=user) + + +@waiting_list_router.post("", response_model=WaitingListEntryRead, status_code=status.HTTP_201_CREATED) +async def create_waiting_list_entry( + body: WaitingListEntryCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).create_waiting_list(tenant_id, body, actor=user) + + +@waiting_list_router.get("", response_model=list[WaitingListEntryRead]) +async def list_waiting_list( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).list_waiting_list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/api/v1/audit.py b/backend/services/healthcare/app/api/v1/audit.py new file mode 100644 index 0000000..cac4cb4 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/audit.py @@ -0,0 +1,30 @@ +"""Delivery audit APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import AUDIT_VIEW +from app.schemas.foundation import HealthcareAuditLogRead +from app.services.audit_service import AuditService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("", response_model=list[HealthcareAuditLogRead]) +async def list_audit( + entity_type: str = Query(...), + entity_id: UUID = Query(...), + limit: int = Query(default=100, ge=1, le=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)), +): + return await AuditService(db).list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/healthcare/app/api/v1/branches.py b/backend/services/healthcare/app/api/v1/branches.py new file mode 100644 index 0000000..9bb0ec8 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/branches.py @@ -0,0 +1,75 @@ +"""Healthcare branch APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + BRANCHES_CREATE, + BRANCHES_DELETE, + BRANCHES_UPDATE, + BRANCHES_VIEW, +) +from app.schemas.foundation import BranchCreate, BranchRead, BranchUpdate +from app.services.foundation import BranchService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=BranchRead, status_code=status.HTTP_201_CREATED) +async def create_branch( + body: BranchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_CREATE)), +): + return await BranchService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[BranchRead]) +async def list_branches( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)), +): + return await BranchService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{branch_id}", response_model=BranchRead) +async def get_branch( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)), +): + return await BranchService(db).get(tenant_id, branch_id) + + +@router.patch("/{branch_id}", response_model=BranchRead) +async def update_branch( + branch_id: UUID, + body: BranchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_UPDATE)), +): + return await BranchService(db).update(tenant_id, branch_id, body, actor=user) + + +@router.post("/{branch_id}/delete", response_model=BranchRead) +async def soft_delete_branch( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_DELETE)), +): + return await BranchService(db).soft_delete(tenant_id, branch_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/clinic_management.py b/backend/services/healthcare/app/api/v1/clinic_management.py new file mode 100644 index 0000000..21aa38d --- /dev/null +++ b/backend/services/healthcare/app/api/v1/clinic_management.py @@ -0,0 +1,119 @@ +"""Clinic management APIs — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.clinic_management import ClinicManagementCommands, ClinicManagementQueries +from app.permissions.definitions import CLINIC_ADMIN_MANAGE, CLINIC_ADMIN_VIEW, CLINIC_SERVICES_MANAGE +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicPolicyRead, + ClinicServiceCreate, + ClinicServiceRead, + OperatingHoursPolicyCreate, + OperatingHoursPolicyRead, + StaffAssignmentCreate, + StaffAssignmentRead, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +services_router = APIRouter() +staff_router = APIRouter() +policies_router = APIRouter() +hours_router = APIRouter() + + +@services_router.post("", response_model=ClinicServiceRead, status_code=status.HTTP_201_CREATED) +async def create_clinic_service( + body: ClinicServiceCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_SERVICES_MANAGE)), +): + return await ClinicManagementCommands(db).create_service(tenant_id, body, actor=user) + + +@services_router.get("", response_model=list[ClinicServiceRead]) +async def list_clinic_services( + clinic_id: UUID | None = Query(default=None), + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_services( + tenant_id, clinic_id=clinic_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@staff_router.post("", response_model=StaffAssignmentRead, status_code=status.HTTP_201_CREATED) +async def create_staff_assignment( + body: StaffAssignmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_staff(tenant_id, body, actor=user) + + +@staff_router.get("", response_model=list[StaffAssignmentRead]) +async def list_staff_assignments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_staff( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@policies_router.post("", response_model=ClinicPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_clinic_policy( + body: ClinicPolicyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_policy(tenant_id, body, actor=user) + + +@policies_router.get("", response_model=list[ClinicPolicyRead]) +async def list_clinic_policies( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_policies( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@hours_router.post("", response_model=OperatingHoursPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_operating_hours( + body: OperatingHoursPolicyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_hours(tenant_id, body, actor=user) + + +@hours_router.get("", response_model=list[OperatingHoursPolicyRead]) +async def list_operating_hours( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_hours( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/api/v1/clinics.py b/backend/services/healthcare/app/api/v1/clinics.py new file mode 100644 index 0000000..46404d5 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/clinics.py @@ -0,0 +1,75 @@ +"""Healthcare clinic APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CLINICS_CREATE, + CLINICS_DELETE, + CLINICS_UPDATE, + CLINICS_VIEW, +) +from app.schemas.foundation import ClinicCreate, ClinicRead, ClinicUpdate +from app.services.foundation import ClinicService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ClinicRead, status_code=status.HTTP_201_CREATED) +async def create_clinic( + body: ClinicCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_CREATE)), +): + return await ClinicService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ClinicRead]) +async def list_clinics( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINICS_VIEW)), +): + return await ClinicService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{clinic_id}", response_model=ClinicRead) +async def get_clinic( + clinic_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINICS_VIEW)), +): + return await ClinicService(db).get(tenant_id, clinic_id) + + +@router.patch("/{clinic_id}", response_model=ClinicRead) +async def update_clinic( + clinic_id: UUID, + body: ClinicUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_UPDATE)), +): + return await ClinicService(db).update(tenant_id, clinic_id, body, actor=user) + + +@router.post("/{clinic_id}/delete", response_model=ClinicRead) +async def soft_delete_clinic( + clinic_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_DELETE)), +): + return await ClinicService(db).soft_delete(tenant_id, clinic_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/configurations.py b/backend/services/healthcare/app/api/v1/configurations.py new file mode 100644 index 0000000..fc2fd1d --- /dev/null +++ b/backend/services/healthcare/app/api/v1/configurations.py @@ -0,0 +1,83 @@ +"""Delivery configuration APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CONFIGURATIONS_CREATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_VIEW, +) +from app.schemas.foundation import ( + HealthcareConfigurationCreate, + HealthcareConfigurationRead, + HealthcareConfigurationUpdate, +) +from app.services.foundation import HealthcareConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HealthcareConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_configuration( + body: HealthcareConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)), +): + return await HealthcareConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HealthcareConfigurationRead]) +async def list_configurations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await HealthcareConfigurationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{configuration_id}", response_model=HealthcareConfigurationRead) +async def get_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await HealthcareConfigurationService(db).get(tenant_id, configuration_id) + + +@router.patch("/{configuration_id}", response_model=HealthcareConfigurationRead) +async def update_configuration( + configuration_id: UUID, + body: HealthcareConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)), +): + return await HealthcareConfigurationService(db).update( + tenant_id, configuration_id, body, actor=user + ) + + +@router.post("/{configuration_id}/delete", response_model=HealthcareConfigurationRead) +async def soft_delete_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)), +): + return await HealthcareConfigurationService(db).soft_delete( + tenant_id, configuration_id, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/delivery_integration.py b/backend/services/healthcare/app/api/v1/delivery_integration.py new file mode 100644 index 0000000..b1586dd --- /dev/null +++ b/backend/services/healthcare/app/api/v1/delivery_integration.py @@ -0,0 +1,90 @@ +"""Delivery integration APIs — Phase 13.7.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DELIVERY_INTEGRATION_MANAGE, + DELIVERY_INTEGRATION_SUBMIT, + DELIVERY_INTEGRATION_VIEW, +) +from app.schemas.delivery_integration import ( + DeliveryIntegrationCreate, + DeliveryIntegrationRead, + DeliveryJobIntentCreate, + DeliveryJobIntentRead, + DeliveryStatusWebhook, +) +from app.services.delivery_integration import DeliveryIntegrationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +integrations_router = APIRouter() +intents_router = APIRouter() +webhooks_router = APIRouter() + + +@integrations_router.post("", response_model=DeliveryIntegrationRead, status_code=status.HTTP_201_CREATED) +async def register_delivery_integration( + body: DeliveryIntegrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_MANAGE)), +): + return await DeliveryIntegrationService(db).register(tenant_id, body, actor=user) + + +@integrations_router.get("", response_model=list[DeliveryIntegrationRead]) +async def list_delivery_integrations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_VIEW)), +): + return await DeliveryIntegrationService(db).list_registrations( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@intents_router.get("", response_model=list[DeliveryJobIntentRead]) +async def list_delivery_job_intents( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_VIEW)), +): + return await DeliveryIntegrationService(db).list_intents( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@intents_router.post( + "/prescription-orders/{prescription_order_id}", + response_model=DeliveryJobIntentRead, + status_code=status.HTTP_201_CREATED, +) +async def create_delivery_intent( + prescription_order_id: UUID, + body: DeliveryJobIntentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_SUBMIT)), +): + return await DeliveryIntegrationService(db).create_intent_for_order( + tenant_id, prescription_order_id, body, actor=user + ) + + +@webhooks_router.post("/delivery-status", response_model=DeliveryJobIntentRead) +async def delivery_status_webhook( + body: DeliveryStatusWebhook, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), +): + return await DeliveryIntegrationService(db).apply_status_webhook(tenant_id, body) diff --git a/backend/services/healthcare/app/api/v1/departments.py b/backend/services/healthcare/app/api/v1/departments.py new file mode 100644 index 0000000..8f1b589 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/departments.py @@ -0,0 +1,75 @@ +"""Healthcare department APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DEPARTMENTS_CREATE, + DEPARTMENTS_DELETE, + DEPARTMENTS_UPDATE, + DEPARTMENTS_VIEW, +) +from app.schemas.foundation import DepartmentCreate, DepartmentRead, DepartmentUpdate +from app.services.foundation import DepartmentService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DepartmentRead, status_code=status.HTTP_201_CREATED) +async def create_department( + body: DepartmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_CREATE)), +): + return await DepartmentService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DepartmentRead]) +async def list_departments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DEPARTMENTS_VIEW)), +): + return await DepartmentService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{department_id}", response_model=DepartmentRead) +async def get_department( + department_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DEPARTMENTS_VIEW)), +): + return await DepartmentService(db).get(tenant_id, department_id) + + +@router.patch("/{department_id}", response_model=DepartmentRead) +async def update_department( + department_id: UUID, + body: DepartmentUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_UPDATE)), +): + return await DepartmentService(db).update(tenant_id, department_id, body, actor=user) + + +@router.post("/{department_id}/delete", response_model=DepartmentRead) +async def soft_delete_department( + department_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_DELETE)), +): + return await DepartmentService(db).soft_delete(tenant_id, department_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/doctor_panel.py b/backend/services/healthcare/app/api/v1/doctor_panel.py new file mode 100644 index 0000000..541f51f --- /dev/null +++ b/backend/services/healthcare/app/api/v1/doctor_panel.py @@ -0,0 +1,183 @@ +"""Doctor panel APIs — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.doctor_panel import DoctorPanelCommands +from app.models.types import VisitSessionStatus +from app.permissions.definitions import ( + DOCTOR_PANEL_VIEW, + VISIT_NOTES_MANAGE, + VISIT_SESSIONS_MANAGE, +) +from app.queries.doctor_panel import DoctorPanelQueries +from app.schemas.doctor_panel import ( + DoctorDashboardRead, + DoctorQueueRead, + VisitNoteCreate, + VisitNoteRead, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, + VisitSessionRead, +) +from app.specifications.doctor_panel import VisitSessionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +doctor_panel_router = APIRouter() +visit_sessions_router = APIRouter() +visit_notes_router = APIRouter() + + +def _visit_spec( + doctor_id: UUID | None = Query(default=None), + clinic_id: UUID | None = Query(default=None), + status_filter: VisitSessionStatus | None = Query(default=None, alias="status"), +) -> VisitSessionListSpec: + return VisitSessionListSpec( + doctor_id=doctor_id, + clinic_id=clinic_id, + status=status_filter, + ) + + +@doctor_panel_router.get("/dashboard", response_model=DoctorDashboardRead) +async def doctor_dashboard( + doctor_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).dashboard(tenant_id, doctor_id=doctor_id) + + +@doctor_panel_router.get("/queue", response_model=DoctorQueueRead) +async def doctor_queue( + doctor_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).queue(tenant_id, doctor_id=doctor_id) + + +@visit_sessions_router.post("", response_model=VisitSessionRead, status_code=status.HTTP_201_CREATED) +async def create_visit_session( + body: VisitSessionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).create_session(tenant_id, body, actor=user) + + +@visit_sessions_router.get("", response_model=list[VisitSessionRead]) +async def list_visit_sessions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: VisitSessionListSpec = Depends(_visit_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).list_sessions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + + +@visit_sessions_router.get("/{visit_session_id}", response_model=VisitSessionRead) +async def get_visit_session( + visit_session_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).get_session(tenant_id, visit_session_id) + + +@visit_sessions_router.post("/{visit_session_id}/start", response_model=VisitSessionRead) +async def start_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).start_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/finish", response_model=VisitSessionRead) +async def finish_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).finish_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/defer", response_model=VisitSessionRead) +async def defer_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).defer_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/call-next", response_model=VisitSessionRead) +async def call_next_visit( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).call_next( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_notes_router.post("", response_model=VisitNoteRead, status_code=status.HTTP_201_CREATED) +async def create_visit_note( + body: VisitNoteCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_NOTES_MANAGE)), +): + return await DoctorPanelCommands(db).create_note(tenant_id, body, actor=user) + + +@visit_notes_router.get("", response_model=list[VisitNoteRead]) +async def list_visit_notes( + visit_session_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).list_notes(tenant_id, visit_session_id) + + +@visit_notes_router.patch("/{note_id}", response_model=VisitNoteRead) +async def update_visit_note( + note_id: UUID, + body: VisitNoteUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_NOTES_MANAGE)), +): + return await DoctorPanelCommands(db).update_note(tenant_id, note_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/doctors.py b/backend/services/healthcare/app/api/v1/doctors.py new file mode 100644 index 0000000..a59502f --- /dev/null +++ b/backend/services/healthcare/app/api/v1/doctors.py @@ -0,0 +1,75 @@ +"""Healthcare doctor APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DOCTORS_CREATE, + DOCTORS_DELETE, + DOCTORS_UPDATE, + DOCTORS_VIEW, +) +from app.schemas.profiles import DoctorCreate, DoctorRead, DoctorUpdate +from app.services.profiles import DoctorService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DoctorRead, status_code=status.HTTP_201_CREATED) +async def create_doctor( + body: DoctorCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_CREATE)), +): + return await DoctorService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DoctorRead]) +async def list_doctors( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTORS_VIEW)), +): + return await DoctorService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{doctor_id}", response_model=DoctorRead) +async def get_doctor( + doctor_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTORS_VIEW)), +): + return await DoctorService(db).get(tenant_id, doctor_id) + + +@router.patch("/{doctor_id}", response_model=DoctorRead) +async def update_doctor( + doctor_id: UUID, + body: DoctorUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_UPDATE)), +): + return await DoctorService(db).update(tenant_id, doctor_id, body, actor=user) + + +@router.post("/{doctor_id}/delete", response_model=DoctorRead) +async def soft_delete_doctor( + doctor_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_DELETE)), +): + return await DoctorService(db).soft_delete(tenant_id, doctor_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/external_providers.py b/backend/services/healthcare/app/api/v1/external_providers.py new file mode 100644 index 0000000..f1f2e90 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/external_providers.py @@ -0,0 +1,83 @@ +"""External provider config APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_VIEW, +) +from app.schemas.foundation import ( + ExternalProviderConfigCreate, + ExternalProviderConfigRead, + ExternalProviderConfigUpdate, +) +from app.services.foundation import ExternalProviderConfigService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED) +async def create_provider( + body: ExternalProviderConfigCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)), +): + return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExternalProviderConfigRead]) +async def list_providers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{provider_id}", response_model=ExternalProviderConfigRead) +async def get_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).get(tenant_id, provider_id) + + +@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead) +async def update_provider( + provider_id: UUID, + body: ExternalProviderConfigUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)), +): + return await ExternalProviderConfigService(db).update( + tenant_id, provider_id, body, actor=user + ) + + +@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead) +async def soft_delete_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)), +): + return await ExternalProviderConfigService(db).soft_delete( + tenant_id, provider_id, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/health.py b/backend/services/healthcare/app/api/v1/health.py new file mode 100644 index 0000000..4e033c3 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/health.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter + +from app import __version__ +from app.core.config import settings + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + return { + "status": "ok", + "service": settings.service_name, + "version": __version__, + } + + +@router.get("/capabilities") +async def capabilities(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "13.7", + "commercial_product": "Torbat Healthcare", + "features": { + "foundation": True, + "health": True, + "capabilities": True, + "metrics": True, + "external_providers_ready": True, + "clinics": True, + "branches": True, + "departments": True, + "doctors": True, + "patients": True, + "appointments": True, + "schedules": True, + "availability": True, + "waiting_list": True, + "visits": True, + "doctor_panel": True, + "clinic_management": True, + "patient_portal": True, + "medical_records": True, + "pharmacy": True, + "delivery_integration": True, + "ai": False, + }, + "independence": { + "standalone": True, + "superapp": True, + "integrations": [ + "accounting", + "communication", + "loyalty", + "crm", + "delivery", + "storage", + "identity", + "ai", + ], + "integration_mode": "api_and_events_only", + }, + } + + +@router.get("/metrics") +async def metrics(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "13.7", + "metrics": { + "clinics": "tenant_scoped", + "branches": "tenant_scoped", + "departments": "tenant_scoped", + "doctors": "tenant_scoped", + "patients": "tenant_scoped", + "external_providers": "tenant_scoped", + "configurations": "tenant_scoped", + "settings": "tenant_scoped", + "outbox_events": "tenant_scoped", + "appointments": "tenant_scoped", + "visits": "tenant_scoped", + "encounters": "tenant_scoped", + "pharmacy_orders": "tenant_scoped", + "delivery_job_intents": "tenant_scoped", + }, + "note": "Healthcare phases 13.0–13.7 complete", + } diff --git a/backend/services/healthcare/app/api/v1/medical_records.py b/backend/services/healthcare/app/api/v1/medical_records.py new file mode 100644 index 0000000..35fb96b --- /dev/null +++ b/backend/services/healthcare/app/api/v1/medical_records.py @@ -0,0 +1,151 @@ +"""Medical record APIs — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ENCOUNTERS_MANAGE, MEDICAL_RECORDS_MANAGE, MEDICAL_RECORDS_VIEW +from app.schemas.medical_records import ( + AllergyCreate, + AllergyRead, + ConditionCreate, + ConditionRead, + EncounterCreate, + EncounterFinalize, + EncounterRead, + EncounterUpdate, + ImmunizationCreate, + ImmunizationRead, + MedicalRecordAccessRequest, + MedicalRecordCreate, + MedicalRecordRead, + MedicationCreate, + MedicationRead, +) +from app.services.medical_records import MedicalRecordService +from shared.security import CurrentUser + +router = APIRouter() +records_router = APIRouter() +encounters_router = APIRouter() + + +class ClinicalListsResponse(BaseModel): + conditions: list[ConditionRead] + allergies: list[AllergyRead] + medications: list[MedicationRead] + immunizations: list[ImmunizationRead] + + +@records_router.post("", response_model=MedicalRecordRead, status_code=status.HTTP_201_CREATED) +async def create_medical_record( + body: MedicalRecordCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).create_record(tenant_id, body, actor=user) + + +@records_router.get("/{record_id}", response_model=MedicalRecordRead) +async def get_medical_record( + record_id: UUID, + reason: str | None = Query(default=None, max_length=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_VIEW)), +): + access = MedicalRecordAccessRequest(reason=reason) + return await MedicalRecordService(db).get_record(tenant_id, record_id, actor=user, access=access) + + +@records_router.get("/{record_id}/clinical-lists", response_model=ClinicalListsResponse) +async def get_clinical_lists( + record_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_VIEW)), +): + data = await MedicalRecordService(db).list_clinical_lists(tenant_id, record_id) + return ClinicalListsResponse(**data) + + +@records_router.post("/{record_id}/conditions", response_model=ConditionRead, status_code=status.HTTP_201_CREATED) +async def add_condition( + record_id: UUID, + body: ConditionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_condition(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/allergies", response_model=AllergyRead, status_code=status.HTTP_201_CREATED) +async def add_allergy( + record_id: UUID, + body: AllergyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_allergy(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/medications", response_model=MedicationRead, status_code=status.HTTP_201_CREATED) +async def add_medication( + record_id: UUID, + body: MedicationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_medication(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/immunizations", response_model=ImmunizationRead, status_code=status.HTTP_201_CREATED) +async def add_immunization( + record_id: UUID, + body: ImmunizationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_immunization(tenant_id, record_id, body, actor=user) + + +@encounters_router.post("", response_model=EncounterRead, status_code=status.HTTP_201_CREATED) +async def create_encounter( + body: EncounterCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).create_encounter(tenant_id, body, actor=user) + + +@encounters_router.patch("/{encounter_id}", response_model=EncounterRead) +async def update_encounter( + encounter_id: UUID, + body: EncounterUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).update_encounter(tenant_id, encounter_id, body, actor=user) + + +@encounters_router.post("/{encounter_id}/finalize", response_model=EncounterRead) +async def finalize_encounter( + encounter_id: UUID, + body: EncounterFinalize, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).finalize_encounter(tenant_id, encounter_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/patient_portal.py b/backend/services/healthcare/app/api/v1/patient_portal.py new file mode 100644 index 0000000..fe53aab --- /dev/null +++ b/backend/services/healthcare/app/api/v1/patient_portal.py @@ -0,0 +1,128 @@ +"""Patient portal APIs — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.patient_deps import require_patient_context +from app.api.permissions import require_permissions +from app.permissions.definitions import PATIENT_PORTAL_MANAGE, PATIENT_PORTAL_VIEW +from app.schemas.appointments import AppointmentListResponse, AppointmentRead +from app.schemas.patient_portal import ( + PatientDocumentRefCreate, + PatientDocumentRefRead, + PatientNotificationPreferenceRead, + PatientNotificationPreferenceUpsert, + PatientPortalAppointmentBook, + PatientPortalAppointmentCancel, + PatientPortalProfileRead, + PatientPortalProfileUpdate, +) +from app.services.patient_portal import PatientPortalService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/me", response_model=PatientPortalProfileRead) +async def get_my_profile( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).get_or_create_profile(tenant_id, patient_id, actor=user) + + +@router.patch("/me", response_model=PatientPortalProfileRead) +async def update_my_profile( + body: PatientPortalProfileUpdate, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).update_profile(tenant_id, patient_id, body, actor=user) + + +@router.get("/appointments", response_model=AppointmentListResponse) +async def list_my_appointments( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + items, total = await PatientPortalService(db).list_appointments(tenant_id, patient_id) + return AppointmentListResponse(items=items, total=total, page=1, page_size=50) + + +@router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED) +async def book_my_appointment( + body: PatientPortalAppointmentBook, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).book_appointment(tenant_id, patient_id, body, actor=user) + + +@router.post("/appointments/{appointment_id}/cancel", response_model=AppointmentRead) +async def cancel_my_appointment( + appointment_id: UUID, + body: PatientPortalAppointmentCancel, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).cancel_appointment( + tenant_id, patient_id, appointment_id, body, actor=user + ) + + +@router.get("/documents", response_model=list[PatientDocumentRefRead]) +async def list_my_documents( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).list_documents(tenant_id, patient_id) + + +@router.post("/documents", response_model=PatientDocumentRefRead, status_code=status.HTTP_201_CREATED) +async def create_my_document( + body: PatientDocumentRefCreate, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).create_document(tenant_id, patient_id, body, actor=user) + + +@router.get("/notification-preferences", response_model=list[PatientNotificationPreferenceRead]) +async def list_my_notification_prefs( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).list_notification_prefs(tenant_id, patient_id) + + +@router.put("/notification-preferences", response_model=PatientNotificationPreferenceRead) +async def upsert_my_notification_pref( + body: PatientNotificationPreferenceUpsert, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).upsert_notification_pref( + tenant_id, patient_id, body, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/patients.py b/backend/services/healthcare/app/api/v1/patients.py new file mode 100644 index 0000000..e18eea8 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/patients.py @@ -0,0 +1,75 @@ +"""Healthcare patient APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + PATIENTS_CREATE, + PATIENTS_DELETE, + PATIENTS_UPDATE, + PATIENTS_VIEW, +) +from app.schemas.profiles import PatientCreate, PatientRead, PatientUpdate +from app.services.profiles import PatientService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=PatientRead, status_code=status.HTTP_201_CREATED) +async def create_patient( + body: PatientCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_CREATE)), +): + return await PatientService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[PatientRead]) +async def list_patients( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENTS_VIEW)), +): + return await PatientService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{patient_id}", response_model=PatientRead) +async def get_patient( + patient_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENTS_VIEW)), +): + return await PatientService(db).get(tenant_id, patient_id) + + +@router.patch("/{patient_id}", response_model=PatientRead) +async def update_patient( + patient_id: UUID, + body: PatientUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_UPDATE)), +): + return await PatientService(db).update(tenant_id, patient_id, body, actor=user) + + +@router.post("/{patient_id}/delete", response_model=PatientRead) +async def soft_delete_patient( + patient_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_DELETE)), +): + return await PatientService(db).soft_delete(tenant_id, patient_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/permissions.py b/backend/services/healthcare/app/api/v1/permissions.py new file mode 100644 index 0000000..f78e1fd --- /dev/null +++ b/backend/services/healthcare/app/api/v1/permissions.py @@ -0,0 +1,27 @@ +"""Permission catalog discovery API — Phase 13.0.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + ALL_PERMISSIONS, + HEALTHCARE_VIEW, + PERMISSION_PREFIXES, +) +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/catalog") +async def permission_catalog( + _user: CurrentUser = Depends(require_permissions(HEALTHCARE_VIEW)), +): + """Static permission discovery for Identity / admin consoles.""" + return { + "prefix": "healthcare.", + "prefixes": list(PERMISSION_PREFIXES), + "permissions": list(ALL_PERMISSIONS), + "count": len(ALL_PERMISSIONS), + } diff --git a/backend/services/healthcare/app/api/v1/pharmacy.py b/backend/services/healthcare/app/api/v1/pharmacy.py new file mode 100644 index 0000000..b96e8c6 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/pharmacy.py @@ -0,0 +1,138 @@ +"""Pharmacy network APIs — Phase 13.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + PHARMACIES_MANAGE, + PHARMACY_VIEW, + PRESCRIPTION_ORDERS_FULFILL, + PRESCRIPTIONS_SUBMIT, +) +from app.schemas.pharmacy import ( + PharmacyCreate, + PharmacyRead, + PrescriptionLifecycleRequest, + PrescriptionOrderCreate, + PrescriptionOrderRead, +) +from app.services.pharmacy import PharmacyService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +pharmacies_router = APIRouter() +prescription_orders_router = APIRouter() + + +@pharmacies_router.post("", response_model=PharmacyRead, status_code=status.HTTP_201_CREATED) +async def create_pharmacy( + body: PharmacyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PHARMACIES_MANAGE)), +): + return await PharmacyService(db).create_pharmacy(tenant_id, body, actor=user) + + +@pharmacies_router.get("", response_model=list[PharmacyRead]) +async def list_pharmacies( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).list_pharmacies( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@prescription_orders_router.post("", response_model=PrescriptionOrderRead, status_code=status.HTTP_201_CREATED) +async def submit_prescription_order( + body: PrescriptionOrderCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTIONS_SUBMIT)), +): + return await PharmacyService(db).submit_order(tenant_id, body, actor=user) + + +@prescription_orders_router.get("", response_model=list[PrescriptionOrderRead]) +async def list_prescription_orders( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).list_orders( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@prescription_orders_router.get("/{order_id}", response_model=PrescriptionOrderRead) +async def get_prescription_order( + order_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).get_order(tenant_id, order_id) + + +@prescription_orders_router.post("/{order_id}/accept", response_model=PrescriptionOrderRead) +async def accept_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).accept(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/prepare", response_model=PrescriptionOrderRead) +async def prepare_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).prepare(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/mark-ready", response_model=PrescriptionOrderRead) +async def mark_prescription_ready( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).mark_ready(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/complete-pickup", response_model=PrescriptionOrderRead) +async def complete_prescription_pickup( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).complete_pickup(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/cancel", response_model=PrescriptionOrderRead) +async def cancel_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).cancel(tenant_id, order_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/settings.py b/backend/services/healthcare/app/api/v1/settings.py new file mode 100644 index 0000000..a2eb5f6 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/settings.py @@ -0,0 +1,39 @@ +"""Delivery settings APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW +from app.schemas.foundation import HealthcareSettingRead, HealthcareSettingUpsert +from app.services.foundation import HealthcareSettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.put("", response_model=HealthcareSettingRead, status_code=status.HTTP_200_OK) +async def upsert_setting( + body: HealthcareSettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)), +): + return await HealthcareSettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HealthcareSettingRead]) +async def list_settings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)), +): + return await HealthcareSettingService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/commands/__init__.py b/backend/services/healthcare/app/commands/__init__.py new file mode 100644 index 0000000..99fbbe3 --- /dev/null +++ b/backend/services/healthcare/app/commands/__init__.py @@ -0,0 +1,4 @@ +"""Profile commands package.""" +from app.commands.profiles import ProfileCommands + +__all__ = ["ProfileCommands"] diff --git a/backend/services/healthcare/app/commands/appointments.py b/backend/services/healthcare/app/commands/appointments.py new file mode 100644 index 0000000..53d197b --- /dev/null +++ b/backend/services/healthcare/app/commands/appointments.py @@ -0,0 +1,72 @@ +"""Appointment command handlers — Phase 13.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentReschedule, + AppointmentTypeCreate, + AvailabilityWindowCreate, + ScheduleCreate, + WaitingListEntryCreate, +) +from app.services.appointments import ( + AppointmentService, + AppointmentTypeService, + AvailabilityWindowService, + ScheduleService, + WaitingListService, +) +from shared.security import CurrentUser + + +class AppointmentCommands: + def __init__(self, session: AsyncSession) -> None: + self.schedules = ScheduleService(session) + self.availability = AvailabilityWindowService(session) + self.types = AppointmentTypeService(session) + self.appointments = AppointmentService(session) + self.waiting_list = WaitingListService(session) + + async def create_schedule(self, tenant_id: UUID, body: ScheduleCreate, *, actor: CurrentUser | None): + return await self.schedules.create(tenant_id, body, actor=actor) + + async def create_availability( + self, tenant_id: UUID, body: AvailabilityWindowCreate, *, actor: CurrentUser | None + ): + return await self.availability.create(tenant_id, body, actor=actor) + + async def create_type(self, tenant_id: UUID, body: AppointmentTypeCreate, *, actor: CurrentUser | None): + return await self.types.create(tenant_id, body, actor=actor) + + async def book(self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None): + return await self.appointments.book(tenant_id, body, actor=actor) + + async def confirm( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.confirm(tenant_id, appointment_id, body, actor=actor) + + async def cancel( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.cancel(tenant_id, appointment_id, body, actor=actor) + + async def reschedule( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentReschedule, *, actor + ): + return await self.appointments.reschedule(tenant_id, appointment_id, body, actor=actor) + + async def mark_no_show( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.mark_no_show(tenant_id, appointment_id, body, actor=actor) + + async def create_waiting_list( + self, tenant_id: UUID, body: WaitingListEntryCreate, *, actor: CurrentUser | None + ): + return await self.waiting_list.create(tenant_id, body, actor=actor) diff --git a/backend/services/healthcare/app/commands/clinic_management.py b/backend/services/healthcare/app/commands/clinic_management.py new file mode 100644 index 0000000..62963ab --- /dev/null +++ b/backend/services/healthcare/app/commands/clinic_management.py @@ -0,0 +1,51 @@ +"""Clinic management command/query handlers — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicServiceCreate, + OperatingHoursPolicyCreate, + StaffAssignmentCreate, +) +from app.services.clinic_management import ClinicManagementService +from shared.security import CurrentUser + + +class ClinicManagementCommands: + def __init__(self, session: AsyncSession) -> None: + self.svc = ClinicManagementService(session) + + async def create_service(self, tenant_id: UUID, body: ClinicServiceCreate, *, actor=None): + return await self.svc.create_service(tenant_id, body, actor=actor) + + async def create_staff(self, tenant_id: UUID, body: StaffAssignmentCreate, *, actor=None): + return await self.svc.create_staff_assignment(tenant_id, body, actor=actor) + + async def create_policy(self, tenant_id: UUID, body: ClinicPolicyCreate, *, actor=None): + return await self.svc.create_policy(tenant_id, body, actor=actor) + + async def create_hours(self, tenant_id: UUID, body: OperatingHoursPolicyCreate, *, actor=None): + return await self.svc.create_operating_hours(tenant_id, body, actor=actor) + + +class ClinicManagementQueries: + def __init__(self, session: AsyncSession) -> None: + self.svc = ClinicManagementService(session) + + async def list_services( + self, tenant_id: UUID, *, clinic_id: UUID | None = None, offset: int = 0, limit: int = 20 + ): + return await self.svc.list_services(tenant_id, clinic_id=clinic_id, offset=offset, limit=limit) + + async def list_staff(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_staff(tenant_id, offset=offset, limit=limit) + + async def list_policies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_policies(tenant_id, offset=offset, limit=limit) + + async def list_hours(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_operating_hours(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/commands/doctor_panel.py b/backend/services/healthcare/app/commands/doctor_panel.py new file mode 100644 index 0000000..503f5d3 --- /dev/null +++ b/backend/services/healthcare/app/commands/doctor_panel.py @@ -0,0 +1,81 @@ +"""Doctor panel command handlers — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.doctor_panel import ( + VisitNoteCreate, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, +) +from app.services.doctor_panel import VisitNoteService, VisitSessionService +from shared.security import CurrentUser + + +class DoctorPanelCommands: + def __init__(self, session: AsyncSession) -> None: + self.sessions = VisitSessionService(session) + self.notes = VisitNoteService(session) + + async def create_session( + self, tenant_id: UUID, body: VisitSessionCreate, *, actor: CurrentUser | None = None + ): + return await self.sessions.create(tenant_id, body, actor=actor) + + async def start_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.start(tenant_id, visit_session_id, body, actor=actor) + + async def finish_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.finish(tenant_id, visit_session_id, body, actor=actor) + + async def defer_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.defer(tenant_id, visit_session_id, body, actor=actor) + + async def call_next( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.call_next(tenant_id, visit_session_id, body, actor=actor) + + async def create_note( + self, tenant_id: UUID, body: VisitNoteCreate, *, actor: CurrentUser | None = None + ): + return await self.notes.create(tenant_id, body, actor=actor) + + async def update_note( + self, + tenant_id: UUID, + note_id: UUID, + body: VisitNoteUpdate, + *, + actor: CurrentUser | None = None, + ): + return await self.notes.update(tenant_id, note_id, body, actor=actor) diff --git a/backend/services/healthcare/app/commands/profiles.py b/backend/services/healthcare/app/commands/profiles.py new file mode 100644 index 0000000..b4a56a6 --- /dev/null +++ b/backend/services/healthcare/app/commands/profiles.py @@ -0,0 +1,46 @@ +"""Profile command handlers — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.profiles import DoctorCreate, DoctorUpdate, PatientCreate, PatientUpdate +from app.services.profiles import DoctorService, PatientService +from shared.security import CurrentUser + + +class ProfileCommands: + def __init__(self, session: AsyncSession) -> None: + self.doctors = DoctorService(session) + self.patients = PatientService(session) + + async def create_doctor( + self, tenant_id: UUID, body: DoctorCreate, *, actor: CurrentUser | None + ): + return await self.doctors.create(tenant_id, body, actor=actor) + + async def update_doctor( + self, + tenant_id: UUID, + doctor_id: UUID, + body: DoctorUpdate, + *, + actor: CurrentUser | None, + ): + return await self.doctors.update(tenant_id, doctor_id, body, actor=actor) + + async def create_patient( + self, tenant_id: UUID, body: PatientCreate, *, actor: CurrentUser | None + ): + return await self.patients.create(tenant_id, body, actor=actor) + + async def update_patient( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientUpdate, + *, + actor: CurrentUser | None, + ): + return await self.patients.update(tenant_id, patient_id, body, actor=actor) diff --git a/backend/services/healthcare/app/core/config.py b/backend/services/healthcare/app/core/config.py new file mode 100644 index 0000000..b8c38ce --- /dev/null +++ b/backend/services/healthcare/app/core/config.py @@ -0,0 +1,75 @@ +"""Healthcare Service settings.""" +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + environment: Literal["development", "staging", "production", "test"] = "development" + debug: bool = True + log_level: str = "INFO" + service_name: str = Field( + default="healthcare-service", validation_alias="HEALTHCARE_SERVICE_NAME" + ) + api_v1_prefix: str = "/api/v1" + + database_url: str = Field( + default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/healthcare_db", + validation_alias="HEALTHCARE_DATABASE_URL", + ) + database_url_sync: str = Field( + default="postgresql+psycopg://superapp:superapp_password@localhost:5432/healthcare_db", + validation_alias="HEALTHCARE_DATABASE_URL_SYNC", + ) + + core_service_url: str = Field( + default="http://localhost:8000", validation_alias="CORE_SERVICE_URL" + ) + + keycloak_enabled: bool = True + keycloak_server_url: str = Field( + default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL" + ) + keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL") + keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM") + + jwt_algorithm: str = "RS256" + jwt_audience: str = "account" + jwt_verify_signature: bool = True + auth_required: bool = True + + cors_origins: str = Field( + default="http://localhost:3000,http://127.0.0.1:3000", + validation_alias="CORS_ORIGINS", + ) + + @property + def keycloak_public_base(self) -> str: + return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/") + + @property + def keycloak_public_realm_url(self) -> str: + return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/services/healthcare/app/core/database.py b/backend/services/healthcare/app/core/database.py new file mode 100644 index 0000000..e44c490 --- /dev/null +++ b/backend/services/healthcare/app/core/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +_engine_kwargs: dict = {"pool_pre_ping": True, "future": True} +if settings.database_url.startswith("sqlite"): + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs["connect_args"] = {"check_same_thread": False} + +engine = create_async_engine(settings.database_url, **_engine_kwargs) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise diff --git a/backend/services/healthcare/app/core/logging.py b/backend/services/healthcare/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/healthcare/app/core/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + stream=sys.stdout, + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/services/healthcare/app/core/security.py b/backend/services/healthcare/app/core/security.py new file mode 100644 index 0000000..4ba748a --- /dev/null +++ b/backend/services/healthcare/app/core/security.py @@ -0,0 +1,49 @@ +"""JWT authentication dependencies for Delivery service.""" +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings +from shared.auth import JWTSettings, JWTValidator +from shared.exceptions import UnauthorizedError +from shared.security import CurrentUser + +_bearer = HTTPBearer(auto_error=False) + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator( + JWTSettings( + keycloak_enabled=settings.keycloak_enabled, + keycloak_server_url=settings.keycloak_server_url, + keycloak_realm=settings.keycloak_realm, + jwt_algorithm=settings.jwt_algorithm, + jwt_audience=settings.jwt_audience, + jwt_verify_signature=settings.jwt_verify_signature, + jwt_issuer=settings.keycloak_public_realm_url, + ) + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + raise UnauthorizedError("توکن احراز هویت ارائه نشده است") + return await get_jwt_validator().validate(credentials.credentials) + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser | None: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + return None + return await get_jwt_validator().validate(credentials.credentials) diff --git a/backend/services/healthcare/app/events/publisher.py b/backend/services/healthcare/app/events/publisher.py new file mode 100644 index 0000000..fc81061 --- /dev/null +++ b/backend/services/healthcare/app/events/publisher.py @@ -0,0 +1,121 @@ +"""Delivery event publisher — in-memory + transactional outbox (ADR-006).""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import EventEnvelope, EventStatus + +from app.core.config import settings +from app.events.types import HealthcareEventType +from app.models.outbox import OutboxEvent + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: ... + + +class InMemoryEventPublisher: + """Records published envelopes for tests and local verification.""" + + def __init__(self) -> None: + self.published: list[EventEnvelope] = [] + + def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + self.published.append(envelope) + return envelope + + def record(self, envelope: EventEnvelope) -> EventEnvelope: + self.published.append(envelope) + return envelope + + +class TransactionalEventPublisher: + """Persist outbox row in the current transaction; mirror to memory in tests.""" + + def __init__( + self, session: AsyncSession, memory: InMemoryEventPublisher | None = None + ) -> None: + self.session = session + if memory is not None: + self.memory = memory + elif settings.environment == "test": + self.memory = get_event_publisher() + else: + self.memory = None + + async def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + row = OutboxEvent( + tenant_id=tenant_id, + event_type=envelope.event_type, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + payload={ + "event_id": str(envelope.event_id), + "source_service": settings.service_name, + **(payload or {}), + }, + status=EventStatus.PENDING, + ) + self.session.add(row) + await self.session.flush() + if self.memory is not None: + self.memory.record(envelope) + return envelope + + +_default_publisher = InMemoryEventPublisher() + + +def get_event_publisher() -> InMemoryEventPublisher: + return _default_publisher + + +def reset_event_publisher() -> InMemoryEventPublisher: + global _default_publisher + _default_publisher = InMemoryEventPublisher() + return _default_publisher diff --git a/backend/services/healthcare/app/events/types.py b/backend/services/healthcare/app/events/types.py new file mode 100644 index 0000000..6b0b495 --- /dev/null +++ b/backend/services/healthcare/app/events/types.py @@ -0,0 +1,63 @@ +"""Healthcare event type contracts (publish-only).""" +from __future__ import annotations + +import enum + + +class HealthcareEventType(str, enum.Enum): + CLINIC_CREATED = "healthcare.clinic.created" + CLINIC_UPDATED = "healthcare.clinic.updated" + BRANCH_CREATED = "healthcare.branch.created" + BRANCH_UPDATED = "healthcare.branch.updated" + DEPARTMENT_CREATED = "healthcare.department.created" + DEPARTMENT_UPDATED = "healthcare.department.updated" + DOCTOR_CREATED = "healthcare.doctor.created" + DOCTOR_UPDATED = "healthcare.doctor.updated" + PATIENT_CREATED = "healthcare.patient.created" + PATIENT_UPDATED = "healthcare.patient.updated" + EXTERNAL_PROVIDER_REGISTERED = "healthcare.external_provider.registered" + EXTERNAL_PROVIDER_UPDATED = "healthcare.external_provider.updated" + CONFIGURATION_CREATED = "healthcare.configuration.created" + CONFIGURATION_UPDATED = "healthcare.configuration.updated" + SETTING_UPDATED = "healthcare.setting.updated" + # Phase 13.1 — Appointment Engine + SCHEDULE_CREATED = "healthcare.schedule.created" + APPOINTMENT_BOOKED = "healthcare.appointment.booked" + APPOINTMENT_CONFIRMED = "healthcare.appointment.confirmed" + APPOINTMENT_CANCELLED = "healthcare.appointment.cancelled" + APPOINTMENT_RESCHEDULED = "healthcare.appointment.rescheduled" + APPOINTMENT_NO_SHOW = "healthcare.appointment.no_show" + APPOINTMENT_REMINDER_SCHEDULED = "healthcare.appointment.reminder_scheduled" + APPOINTMENT_CHECKED_IN = "healthcare.appointment.checked_in" + APPOINTMENT_COMPLETED = "healthcare.appointment.completed" + # Phase 13.2 — Doctor Panel + VISIT_SESSION_CREATED = "healthcare.visit_session.created" + VISIT_SESSION_STARTED = "healthcare.visit_session.started" + VISIT_SESSION_FINISHED = "healthcare.visit_session.finished" + VISIT_SESSION_DEFERRED = "healthcare.visit_session.deferred" + VISIT_SESSION_CALLED = "healthcare.visit_session.called" + VISIT_NOTE_CREATED = "healthcare.visit_note.created" + VISIT_NOTE_UPDATED = "healthcare.visit_note.updated" + # Phase 13.3 — Clinic Management + CLINIC_SERVICE_CREATED = "healthcare.clinic_service.created" + STAFF_ASSIGNMENT_CREATED = "healthcare.staff_assignment.created" + CLINIC_POLICY_CREATED = "healthcare.clinic_policy.created" + OPERATING_HOURS_POLICY_CREATED = "healthcare.operating_hours_policy.created" + # Phase 13.4 — Patient Portal + PATIENT_PORTAL_PROFILE_CREATED = "healthcare.patient_portal.profile_created" + PATIENT_PORTAL_PROFILE_UPDATED = "healthcare.patient_portal.profile_updated" + PATIENT_DOCUMENT_REF_CREATED = "healthcare.patient_portal.document_ref_created" + PATIENT_NOTIFICATION_PREF_UPDATED = "healthcare.patient_portal.notification_pref_updated" + # Phase 13.5 — Medical Record + MEDICAL_RECORD_CREATED = "healthcare.medical_record.created" + ENCOUNTER_CREATED = "healthcare.encounter.created" + ENCOUNTER_FINALIZED = "healthcare.encounter.finalized" + MEDICAL_RECORD_ACCESSED = "healthcare.medical_record.accessed" + # Phase 13.6 — Pharmacy Network + PHARMACY_CREATED = "healthcare.pharmacy.created" + PRESCRIPTION_ORDER_SUBMITTED = "healthcare.prescription_order.submitted" + PRESCRIPTION_ORDER_STATUS_CHANGED = "healthcare.prescription_order.status_changed" + # Phase 13.7 — Delivery Integration + DELIVERY_INTEGRATION_REGISTERED = "healthcare.delivery_integration.registered" + DELIVERY_JOB_INTENT_CREATED = "healthcare.delivery_job_intent.created" + DELIVERY_JOB_STATUS_UPDATED = "healthcare.delivery_job_intent.status_updated" diff --git a/backend/services/healthcare/app/main.py b/backend/services/healthcare/app/main.py new file mode 100644 index 0000000..8b35b3c --- /dev/null +++ b/backend/services/healthcare/app/main.py @@ -0,0 +1,139 @@ +from contextlib import asynccontextmanager + + + +from fastapi import FastAPI, Request + +from fastapi.middleware.cors import CORSMiddleware + +from fastapi.responses import JSONResponse + + + +from app import __version__ + +from app.api.v1 import api_router + +from app.api.v1 import health + +from app.core.config import settings + +from app.core.logging import configure_logging, get_logger + +from app.middlewares.tenant import TenantHeaderMiddleware + +from shared.exceptions import AppError + +from shared.responses import ErrorDetail, ErrorResponse + + + +configure_logging(settings.log_level) + +logger = get_logger(__name__) + + + + + +@asynccontextmanager + +async def lifespan(app: FastAPI): + + logger.info("service_starting", extra={"service": settings.service_name}) + + yield + + + + + +def create_app() -> FastAPI: + + app = FastAPI( + + title="Healthcare Platform", + + version=__version__, + + description="Torbat Healthcare — Healthcare Platform (Phase 13.7 Delivery Integration)", + + lifespan=lifespan, + + ) + + app.add_middleware( + + CORSMiddleware, + + allow_origins=settings.cors_origin_list, + + allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir", + + allow_credentials=False, + + allow_methods=["*"], + + allow_headers=["*"], + + ) + + app.add_middleware(TenantHeaderMiddleware) + + + + @app.exception_handler(AppError) + + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + + return JSONResponse( + + status_code=exc.status_code, + + content=ErrorResponse( + + error=ErrorDetail( + + code=exc.error_code, message=exc.message, details=exc.details + + ) + + ).model_dump(), + + ) + + + + @app.exception_handler(Exception) + + async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse: + + logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc) + + return JSONResponse( + + status_code=500, + + content=ErrorResponse( + + error=ErrorDetail(code="internal_error", message="خطای داخلی سرور") + + ).model_dump(), + + ) + + + + app.include_router(health.router) + + app.include_router(api_router, prefix=settings.api_v1_prefix) + + return app + + + + + +app = create_app() + + diff --git a/backend/services/healthcare/app/middlewares/tenant.py b/backend/services/healthcare/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/healthcare/app/middlewares/tenant.py @@ -0,0 +1,24 @@ +"""Tenant header resolution middleware.""" +from __future__ import annotations + +from uuid import UUID + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID + + +class TenantHeaderMiddleware(BaseHTTPMiddleware): + """Resolve tenant from X-Tenant-ID header only (microservice pattern).""" + + async def dispatch(self, request: Request, call_next) -> Response: + setattr(request.state, STATE_TENANT_ID, None) + raw = request.headers.get(HEADER_TENANT_ID) + if raw: + try: + setattr(request.state, STATE_TENANT_ID, UUID(raw)) + except ValueError: + pass + return await call_next(request) diff --git a/backend/services/healthcare/app/models/__init__.py b/backend/services/healthcare/app/models/__init__.py new file mode 100644 index 0000000..f0028b7 --- /dev/null +++ b/backend/services/healthcare/app/models/__init__.py @@ -0,0 +1,53 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.foundation import ( # noqa: F401 + Branch, + Clinic, + Department, + ExternalProviderConfig, + HealthcareAuditLog, + HealthcareConfiguration, + HealthcarePermission, + HealthcareRole, + HealthcareSetting, +) +from app.models.outbox import OutboxEvent # noqa: F401 +from app.models.profiles import Doctor, Patient # noqa: F401 +from app.models.appointments import ( # noqa: F401 + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.doctor_panel import VisitNote, VisitSession # noqa: F401 +from app.models.clinic_management import ( # noqa: F401 + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.models.patient_portal import ( # noqa: F401 + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.models.medical_records import ( # noqa: F401 + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.models.pharmacy import ( # noqa: F401 + Pharmacy, + PrescriptionLine, + PrescriptionOrder, + PrescriptionStatusHistory, +) +from app.models.delivery_integration import ( # noqa: F401 + DeliveryIntegrationRegistration, + DeliveryJobIntent, + DeliveryJobStatusSnapshot, +) diff --git a/backend/services/healthcare/app/models/appointments.py b/backend/services/healthcare/app/models/appointments.py new file mode 100644 index 0000000..fc196a2 --- /dev/null +++ b/backend/services/healthcare/app/models/appointments.py @@ -0,0 +1,164 @@ +"""Appointment scheduling aggregates — Phase 13.1.""" +from __future__ import annotations + +import uuid +from datetime import datetime, time + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, Time, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AppointmentStatus, DayOfWeek, GUID, LifecycleStatus + + +class Schedule( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "schedules" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + day_of_week: Mapped[DayOfWeek] = mapped_column(nullable=False) + start_time: Mapped[time] = mapped_column(Time, nullable=False) + end_time: Mapped[time] = mapped_column(Time, nullable=False) + slot_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_schedules_tenant_code"), + Index("ix_schedules_doctor", "tenant_id", "doctor_id"), + Index("ix_schedules_clinic", "tenant_id", "clinic_id"), + ) + + +class AvailabilityWindow( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "availability_windows" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + is_bookable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_availability_windows_tenant_code"), + Index("ix_availability_windows_doctor", "tenant_id", "doctor_id"), + Index("ix_availability_windows_starts", "tenant_id", "starts_at"), + ) + + +class AppointmentType( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "appointment_types" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + default_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_appointment_types_tenant_code"), + Index("ix_appointment_types_clinic", "tenant_id", "clinic_id"), + ) + + +class Appointment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "appointments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + appointment_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[AppointmentStatus] = mapped_column( + default=AppointmentStatus.DRAFT, nullable=False + ) + scheduled_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + scheduled_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + status_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + reminder_scheduled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_appointments_tenant_code"), + Index("ix_appointments_tenant_status", "tenant_id", "status"), + Index("ix_appointments_doctor", "tenant_id", "doctor_id"), + Index("ix_appointments_patient", "tenant_id", "patient_id"), + Index("ix_appointments_scheduled_start", "tenant_id", "scheduled_start"), + ) + + +class WaitingListEntry( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "waiting_list_entries" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + preferred_date: Mapped[str | None] = mapped_column(String(32), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_waiting_list_entries_tenant_code"), + Index("ix_waiting_list_entries_clinic", "tenant_id", "clinic_id"), + Index("ix_waiting_list_entries_patient", "tenant_id", "patient_id"), + ) diff --git a/backend/services/healthcare/app/models/base.py b/backend/services/healthcare/app/models/base.py new file mode 100644 index 0000000..873bc66 --- /dev/null +++ b/backend/services/healthcare/app/models/base.py @@ -0,0 +1,51 @@ +"""Shared model mixins.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.types import GUID + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column( + GUID(), primary_key=True, default=uuid.uuid4 + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class TenantMixin: + """Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded.""" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class ActorAuditMixin: + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class OptimisticLockMixin: + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) diff --git a/backend/services/healthcare/app/models/clinic_management.py b/backend/services/healthcare/app/models/clinic_management.py new file mode 100644 index 0000000..540e951 --- /dev/null +++ b/backend/services/healthcare/app/models/clinic_management.py @@ -0,0 +1,126 @@ +"""Clinic management aggregates — Phase 13.3.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ClinicPolicyKind, GUID, LifecycleStatus + + +class ClinicService( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "clinic_services" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + pricing_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinic_services_tenant_code"), + Index("ix_clinic_services_clinic", "tenant_id", "clinic_id"), + ) + + +class StaffAssignment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_assignments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + role_code: Mapped[str] = mapped_column(String(50), nullable=False) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_staff_assignments_tenant_code"), + Index("ix_staff_assignments_clinic", "tenant_id", "clinic_id"), + Index("ix_staff_assignments_doctor", "tenant_id", "doctor_id"), + ) + + +class ClinicPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "clinic_policies" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[ClinicPolicyKind] = mapped_column(nullable=False) + booking_lead_time_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False) + cancellation_window_hours: Mapped[int] = mapped_column(Integer, default=12, nullable=False) + privacy_flags: Mapped[dict | None] = mapped_column(JSON, nullable=True) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinic_policies_tenant_code"), + Index("ix_clinic_policies_clinic", "tenant_id", "clinic_id"), + ) + + +class OperatingHoursPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "operating_hours_policies" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + weekly_hours: Mapped[dict] = mapped_column(JSON, nullable=False) + exceptions: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_operating_hours_policies_tenant_code"), + Index("ix_operating_hours_clinic", "tenant_id", "clinic_id"), + ) diff --git a/backend/services/healthcare/app/models/delivery_integration.py b/backend/services/healthcare/app/models/delivery_integration.py new file mode 100644 index 0000000..ba305e1 --- /dev/null +++ b/backend/services/healthcare/app/models/delivery_integration.py @@ -0,0 +1,100 @@ +"""Delivery integration aggregates — Phase 13.7.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import DeliveryJobStatus, GUID, LifecycleStatus + + +class DeliveryIntegrationRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "delivery_integration_registrations" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + pharmacy_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + delivery_merchant_ref: Mapped[str] = mapped_column(String(255), nullable=False) + webhook_secret_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_delivery_integration_registrations_tenant_code" + ), + Index("ix_delivery_integration_registrations_tenant", "tenant_id", "status"), + ) + + +class DeliveryJobIntent( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "delivery_job_intents" + + registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + delivery_job_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[DeliveryJobStatus] = mapped_column( + default=DeliveryJobStatus.QUEUED, nullable=False + ) + idempotency_key: Mapped[str] = mapped_column(String(100), nullable=False) + pickup_address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + dropoff_address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_delivery_job_intents_tenant_code"), + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_delivery_job_intents_idempotency" + ), + Index("ix_delivery_job_intents_order", "tenant_id", "prescription_order_id"), + ) + + +class DeliveryJobStatusSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "delivery_job_status_snapshots" + + delivery_job_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[DeliveryJobStatus] = mapped_column(nullable=False) + external_status: Mapped[str | None] = mapped_column(String(100), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index( + "ix_delivery_job_status_snapshots_intent", + "tenant_id", + "delivery_job_intent_id", + ), + ) diff --git a/backend/services/healthcare/app/models/doctor_panel.py b/backend/services/healthcare/app/models/doctor_panel.py new file mode 100644 index 0000000..cf77b2f --- /dev/null +++ b/backend/services/healthcare/app/models/doctor_panel.py @@ -0,0 +1,76 @@ +"""Doctor panel aggregates — Phase 13.2.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, VisitSessionStatus + + +class VisitSession( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "visit_sessions" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column( + default=VisitSessionStatus.QUEUED, nullable=False + ) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + + +class VisitNote( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "visit_notes" + + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) diff --git a/backend/services/healthcare/app/models/foundation.py b/backend/services/healthcare/app/models/foundation.py new file mode 100644 index 0000000..3ab4d09 --- /dev/null +++ b/backend/services/healthcare/app/models/foundation.py @@ -0,0 +1,296 @@ +"""Healthcare foundation aggregates — Phase 13.0. + +Independent aggregates use UUID references within healthcare_db only. +No SQLAlchemy relationship graphs between aggregates. +No appointment/clinical workflow engines — shells and contracts only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AuditAction, GUID, LifecycleStatus, ProviderKind, ProviderStatus + + +class Clinic( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Root healthcare clinic shell for a tenant.""" + + __tablename__ = "clinics" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.DRAFT, nullable=False + ) + legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinics_tenant_code"), + Index("ix_clinics_tenant_status", "tenant_id", "status"), + Index("ix_clinics_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Branch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Operational branch under a clinic.""" + + __tablename__ = "branches" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "clinic_id", "code", name="uq_branches_tenant_code" + ), + Index("ix_branches_clinic", "tenant_id", "clinic_id"), + Index("ix_branches_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Department( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Clinical department shell under a branch/clinic.""" + + __tablename__ = "departments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + specialty: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "clinic_id", "code", name="uq_departments_tenant_code" + ), + Index("ix_departments_clinic", "tenant_id", "clinic_id"), + Index("ix_departments_branch", "tenant_id", "branch_id"), + Index("ix_departments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareRole( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local healthcare RBAC role catalog shell.""" + + __tablename__ = "healthcare_roles" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_healthcare_roles_tenant_code"), + Index("ix_healthcare_roles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcarePermission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local healthcare permission catalog shell (complements platform healthcare.* tree).""" + + __tablename__ = "healthcare_permissions" + + role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_healthcare_permissions_tenant_code"), + Index("ix_healthcare_permissions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExternalProviderConfig( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """External provider registration shell — credentials stay here.""" + + __tablename__ = "external_provider_configs" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + provider_kind: Mapped[ProviderKind] = mapped_column( + default=ProviderKind.CUSTOM, nullable=False + ) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[ProviderStatus] = mapped_column( + default=ProviderStatus.REGISTERED, nullable=False + ) + priority: Mapped[int] = mapped_column(default=100, nullable=False) + credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_external_provider_configs_tenant_code" + ), + Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"), + Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareConfiguration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant healthcare configuration — hours, policies, locale shells.""" + + __tablename__ = "healthcare_configurations" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + clinical_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + privacy_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "clinic_id", + "code", + name="uq_healthcare_configurations_tenant_code", + ), + Index("ix_healthcare_configurations_clinic", "tenant_id", "clinic_id"), + Index("ix_healthcare_configurations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareSetting( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Key/value healthcare settings shell.""" + + __tablename__ = "healthcare_settings" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[dict | None] = mapped_column(JSON, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "clinic_id", + "branch_id", + "key", + name="uq_healthcare_settings_tenant_key", + ), + Index("ix_healthcare_settings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only healthcare audit trail.""" + + __tablename__ = "healthcare_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[AuditAction] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index( + "ix_healthcare_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/healthcare/app/models/medical_records.py b/backend/services/healthcare/app/models/medical_records.py new file mode 100644 index 0000000..f39ad27 --- /dev/null +++ b/backend/services/healthcare/app/models/medical_records.py @@ -0,0 +1,178 @@ +"""Medical record aggregates — Phase 13.5.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import EncounterStatus, GUID + + +class MedicalRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "medical_records" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + access_policy: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_medical_records_tenant_code"), + Index("ix_medical_records_patient", "tenant_id", "patient_id"), + ) + + +class Encounter( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "encounters" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + visit_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + appointment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[EncounterStatus] = mapped_column( + default=EncounterStatus.DRAFT, nullable=False + ) + chief_complaint: Mapped[str | None] = mapped_column(Text, nullable=True) + clinical_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + addendum: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_encounters_tenant_code"), + Index("ix_encounters_record", "tenant_id", "medical_record_id"), + ) + + +class Condition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "conditions" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + clinical_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_conditions_tenant_code"), + Index("ix_conditions_record", "tenant_id", "medical_record_id"), + ) + + +class Allergy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "allergies" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + substance: Mapped[str] = mapped_column(String(255), nullable=False) + severity: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_allergies_tenant_code"), + Index("ix_allergies_record", "tenant_id", "medical_record_id"), + ) + + +class MedicationStatement( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "medication_statements" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + medication_code: Mapped[str] = mapped_column(String(100), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + dosage: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_medication_statements_tenant_code"), + Index("ix_medication_statements_record", "tenant_id", "medical_record_id"), + ) + + +class ImmunizationRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "immunization_records" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + vaccine_code: Mapped[str] = mapped_column(String(100), nullable=False) + administered_on: Mapped[str | None] = mapped_column(String(32), nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_immunization_records_tenant_code"), + Index("ix_immunization_records_record", "tenant_id", "medical_record_id"), + ) + + +class MedicalRecordAccessLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "medical_record_access_logs" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False) + action: Mapped[str] = mapped_column(String(50), nullable=False) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_medical_record_access_logs_record", "tenant_id", "medical_record_id"), + ) diff --git a/backend/services/healthcare/app/models/outbox.py b/backend/services/healthcare/app/models/outbox.py new file mode 100644 index 0000000..136e233 --- /dev/null +++ b/backend/services/healthcare/app/models/outbox.py @@ -0,0 +1,44 @@ +"""Transactional outbox model — ADR-006.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import UUIDPrimaryKeyMixin +from app.models.types import GUID +from shared.events import EventStatus + + +class OutboxEvent(UUIDPrimaryKeyMixin, Base): + """Pending domain events written in the same DB transaction as mutations.""" + + __tablename__ = "outbox_events" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + event_type: Mapped[str] = mapped_column(String(150), nullable=False) + aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False) + aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + status: Mapped[EventStatus] = mapped_column( + SAEnum(EventStatus, name="healthcare_event_status", native_enum=False), + default=EventStatus.PENDING, + nullable=False, + ) + retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_healthcare_outbox_status", "status"), + Index("ix_healthcare_outbox_tenant", "tenant_id"), + ) diff --git a/backend/services/healthcare/app/models/patient_portal.py b/backend/services/healthcare/app/models/patient_portal.py new file mode 100644 index 0000000..25adcb2 --- /dev/null +++ b/backend/services/healthcare/app/models/patient_portal.py @@ -0,0 +1,94 @@ +"""Patient portal aggregates — Phase 13.4.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID + + +class PatientPortalProfile( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "patient_portal_profiles" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + preferred_clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + preferred_language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + emergency_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + notification_prefs: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "patient_id", name="uq_patient_portal_profiles_patient"), + Index("ix_patient_portal_profiles_tenant", "tenant_id", "patient_id"), + ) + + +class PatientDocumentRef( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "patient_document_refs" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + document_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_patient_document_refs_tenant_code"), + Index("ix_patient_document_refs_patient", "tenant_id", "patient_id"), + ) + + +class PatientNotificationPreference( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "patient_notification_preferences" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + channel: Mapped[str] = mapped_column(String(32), nullable=False) + template_key: Mapped[str] = mapped_column(String(100), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "patient_id", + "channel", + "template_key", + name="uq_patient_notification_prefs", + ), + Index("ix_patient_notification_prefs_patient", "tenant_id", "patient_id"), + ) diff --git a/backend/services/healthcare/app/models/pharmacy.py b/backend/services/healthcare/app/models/pharmacy.py new file mode 100644 index 0000000..02c30fb --- /dev/null +++ b/backend/services/healthcare/app/models/pharmacy.py @@ -0,0 +1,124 @@ +"""Pharmacy network aggregates — Phase 13.6.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, PrescriptionOrderStatus + + +class Pharmacy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "pharmacies" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + service_area_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_pharmacies_tenant_code"), + Index("ix_pharmacies_tenant", "tenant_id", "status"), + ) + + +class PrescriptionOrder( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "prescription_orders" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + encounter_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + visit_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[PrescriptionOrderStatus] = mapped_column( + default=PrescriptionOrderStatus.SUBMITTED, nullable=False + ) + status_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_prescription_orders_tenant_code"), + Index("ix_prescription_orders_pharmacy", "tenant_id", "pharmacy_id"), + Index("ix_prescription_orders_status", "tenant_id", "status"), + ) + + +class PrescriptionLine( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "prescription_lines" + + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + line_no: Mapped[int] = mapped_column(Integer, nullable=False) + medication_code: Mapped[str] = mapped_column(String(100), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + dosage_instructions: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "prescription_order_id", + "line_no", + name="uq_prescription_lines_order_line", + ), + Index("ix_prescription_lines_order", "tenant_id", "prescription_order_id"), + ) + + +class PrescriptionStatusHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "prescription_status_history" + + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + from_status: Mapped[PrescriptionOrderStatus] = mapped_column(nullable=False) + to_status: Mapped[PrescriptionOrderStatus] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_prescription_status_history_order", "tenant_id", "prescription_order_id"), + ) diff --git a/backend/services/healthcare/app/models/profiles.py b/backend/services/healthcare/app/models/profiles.py new file mode 100644 index 0000000..bfd4762 --- /dev/null +++ b/backend/services/healthcare/app/models/profiles.py @@ -0,0 +1,85 @@ +"""Doctor and Patient profile shells — Phase 13.0.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, ProfileStatus + + +class Doctor( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Doctor profile shell — links to Identity user ref only.""" + + __tablename__ = "doctors" + + user_id: Mapped[str] = mapped_column(String(100), nullable=False) + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + specialty: Mapped[str | None] = mapped_column(String(100), nullable=True) + license_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[ProfileStatus] = mapped_column( + default=ProfileStatus.ACTIVE, nullable=False + ) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_doctors_tenant_code"), + UniqueConstraint("tenant_id", "user_id", name="uq_doctors_tenant_user"), + Index("ix_doctors_clinic", "tenant_id", "clinic_id"), + Index("ix_doctors_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Patient( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Patient profile shell — links to Identity user ref only.""" + + __tablename__ = "patients" + + user_id: Mapped[str] = mapped_column(String(100), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[ProfileStatus] = mapped_column( + default=ProfileStatus.ACTIVE, nullable=False + ) + date_of_birth: Mapped[str | None] = mapped_column(String(32), nullable=True) + gender: Mapped[str | None] = mapped_column(String(32), nullable=True) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + emergency_contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_patients_tenant_code"), + UniqueConstraint("tenant_id", "user_id", name="uq_patients_tenant_user"), + Index("ix_patients_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/healthcare/app/models/types.py b/backend/services/healthcare/app/models/types.py new file mode 100644 index 0000000..8a9a50e --- /dev/null +++ b/backend/services/healthcare/app/models/types.py @@ -0,0 +1,175 @@ +"""Healthcare domain enums and dialect-safe GUID type.""" +from __future__ import annotations + +import enum +import uuid + +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if dialect.name == "postgresql": + return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value)) + return str(value) + + def process_result_value(self, value, dialect): + if value is None: + return value + if isinstance(value, uuid.UUID): + return value + return uuid.UUID(str(value)) + + +class LifecycleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProfileStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProviderKind(str, enum.Enum): + COMMUNICATION = "communication" + CRM = "crm" + LOYALTY = "loyalty" + ACCOUNTING = "accounting" + DELIVERY = "delivery" + STORAGE = "storage" + AI = "ai" + IDENTITY = "identity" + CUSTOM = "custom" + + +class ProviderStatus(str, enum.Enum): + REGISTERED = "registered" + ACTIVE = "active" + INACTIVE = "inactive" + FAILED = "failed" + RETIRED = "retired" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + REGISTER = "register" + ENABLE = "enable" + DISABLE = "disable" + + +# Phase 13.1 — Appointment Engine +class DayOfWeek(str, enum.Enum): + MONDAY = "monday" + TUESDAY = "tuesday" + WEDNESDAY = "wednesday" + THURSDAY = "thursday" + FRIDAY = "friday" + SATURDAY = "saturday" + SUNDAY = "sunday" + + +class AppointmentStatus(str, enum.Enum): + DRAFT = "draft" + CONFIRMED = "confirmed" + CHECKED_IN = "checked_in" + COMPLETED = "completed" + CANCELLED = "cancelled" + NO_SHOW = "no_show" + + +class AppointmentLifecycleAction(str, enum.Enum): + BOOK = "book" + CONFIRM = "confirm" + CHECK_IN = "check_in" + COMPLETE = "complete" + CANCEL = "cancel" + RESCHEDULE = "reschedule" + MARK_NO_SHOW = "mark_no_show" + + +# Phase 13.2 — Doctor Panel +class VisitSessionStatus(str, enum.Enum): + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + DEFERRED = "deferred" + CANCELLED = "cancelled" + + +class VisitSessionAction(str, enum.Enum): + CALL_NEXT = "call_next" + START = "start" + FINISH = "finish" + DEFER = "defer" + + +# Phase 13.3 — Clinic Management +class ClinicPolicyKind(str, enum.Enum): + BOOKING = "booking" + CANCELLATION = "cancellation" + PRIVACY = "privacy" + + +# Phase 13.5 — Medical Record +class EncounterStatus(str, enum.Enum): + DRAFT = "draft" + IN_PROGRESS = "in_progress" + FINALIZED = "finalized" + + +class ClinicalListKind(str, enum.Enum): + CONDITION = "condition" + ALLERGY = "allergy" + MEDICATION = "medication" + IMMUNIZATION = "immunization" + + +# Phase 13.6 — Pharmacy Network +class PrescriptionOrderStatus(str, enum.Enum): + SUBMITTED = "submitted" + ACCEPTED = "accepted" + PREPARING = "preparing" + READY = "ready" + PICKED_UP = "picked_up" + CANCELLED = "cancelled" + + +class PrescriptionLifecycleAction(str, enum.Enum): + SUBMIT = "submit" + ACCEPT = "accept" + PREPARE = "prepare" + MARK_READY = "mark_ready" + COMPLETE_PICKUP = "complete_pickup" + CANCEL = "cancel" + + +# Phase 13.7 — Delivery Integration +class DeliveryJobStatus(str, enum.Enum): + QUEUED = "queued" + PICKED_UP = "picked_up" + IN_TRANSIT = "in_transit" + DELIVERED = "delivered" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/backend/services/healthcare/app/permissions/definitions.py b/backend/services/healthcare/app/permissions/definitions.py new file mode 100644 index 0000000..c7286f6 --- /dev/null +++ b/backend/services/healthcare/app/permissions/definitions.py @@ -0,0 +1,183 @@ +"""Healthcare permission definitions — Phase 13.0 foundation.""" +from __future__ import annotations + +HEALTHCARE_VIEW = "healthcare.view" +HEALTHCARE_MANAGE = "healthcare.manage" + +CLINICS_VIEW = "healthcare.clinics.view" +CLINICS_CREATE = "healthcare.clinics.create" +CLINICS_UPDATE = "healthcare.clinics.update" +CLINICS_DELETE = "healthcare.clinics.delete" +CLINICS_MANAGE = "healthcare.clinics.manage" + +BRANCHES_VIEW = "healthcare.branches.view" +BRANCHES_CREATE = "healthcare.branches.create" +BRANCHES_UPDATE = "healthcare.branches.update" +BRANCHES_DELETE = "healthcare.branches.delete" +BRANCHES_MANAGE = "healthcare.branches.manage" + +DEPARTMENTS_VIEW = "healthcare.departments.view" +DEPARTMENTS_CREATE = "healthcare.departments.create" +DEPARTMENTS_UPDATE = "healthcare.departments.update" +DEPARTMENTS_DELETE = "healthcare.departments.delete" +DEPARTMENTS_MANAGE = "healthcare.departments.manage" + +DOCTORS_VIEW = "healthcare.doctors.view" +DOCTORS_CREATE = "healthcare.doctors.create" +DOCTORS_UPDATE = "healthcare.doctors.update" +DOCTORS_DELETE = "healthcare.doctors.delete" +DOCTORS_MANAGE = "healthcare.doctors.manage" + +PATIENTS_VIEW = "healthcare.patients.view" +PATIENTS_CREATE = "healthcare.patients.create" +PATIENTS_UPDATE = "healthcare.patients.update" +PATIENTS_DELETE = "healthcare.patients.delete" +PATIENTS_MANAGE = "healthcare.patients.manage" + +EXTERNAL_PROVIDERS_VIEW = "healthcare.external_providers.view" +EXTERNAL_PROVIDERS_CREATE = "healthcare.external_providers.create" +EXTERNAL_PROVIDERS_UPDATE = "healthcare.external_providers.update" +EXTERNAL_PROVIDERS_DELETE = "healthcare.external_providers.delete" +EXTERNAL_PROVIDERS_MANAGE = "healthcare.external_providers.manage" + +CONFIGURATIONS_VIEW = "healthcare.configurations.view" +CONFIGURATIONS_CREATE = "healthcare.configurations.create" +CONFIGURATIONS_UPDATE = "healthcare.configurations.update" +CONFIGURATIONS_DELETE = "healthcare.configurations.delete" +CONFIGURATIONS_MANAGE = "healthcare.configurations.manage" + +SETTINGS_VIEW = "healthcare.settings.view" +SETTINGS_MANAGE = "healthcare.settings.manage" + +AUDIT_VIEW = "healthcare.audit.view" + +# Planned trees (registered for discovery; enforced in later phases) +APPOINTMENTS_VIEW = "healthcare.appointments.view" +APPOINTMENTS_MANAGE = "healthcare.appointments.manage" +SCHEDULES_VIEW = "healthcare.schedules.view" +SCHEDULES_MANAGE = "healthcare.schedules.manage" +DOCTOR_PANEL_VIEW = "healthcare.doctor_panel.view" +VISIT_SESSIONS_MANAGE = "healthcare.visit_sessions.manage" +VISIT_NOTES_MANAGE = "healthcare.visit_notes.manage" +CLINIC_ADMIN_VIEW = "healthcare.clinic_admin.view" +CLINIC_ADMIN_MANAGE = "healthcare.clinic_admin.manage" +CLINIC_SERVICES_VIEW = "healthcare.clinic_services.view" +CLINIC_SERVICES_MANAGE = "healthcare.clinic_services.manage" +PATIENT_PORTAL_VIEW = "healthcare.patient_portal.view" +PATIENT_PORTAL_MANAGE = "healthcare.patient_portal.manage" +MEDICAL_RECORDS_VIEW = "healthcare.medical_records.view" +MEDICAL_RECORDS_MANAGE = "healthcare.medical_records.manage" +ENCOUNTERS_MANAGE = "healthcare.encounters.manage" +PRESCRIPTIONS_SUBMIT = "healthcare.prescriptions.submit" +PHARMACIES_MANAGE = "healthcare.pharmacies.manage" +PRESCRIPTION_ORDERS_FULFILL = "healthcare.prescription_orders.fulfill" +DELIVERY_INTEGRATION_VIEW = "healthcare.delivery_integration.view" +DELIVERY_INTEGRATION_MANAGE = "healthcare.delivery_integration.manage" +DELIVERY_INTEGRATION_SUBMIT = "healthcare.delivery_integration.submit" +VISITS_VIEW = "healthcare.visits.view" +VISITS_MANAGE = "healthcare.visits.manage" +PHARMACY_VIEW = "healthcare.pharmacy.view" +PHARMACY_MANAGE = "healthcare.pharmacy.manage" +RECORDS_VIEW = "healthcare.records.view" +RECORDS_MANAGE = "healthcare.records.manage" + +ALL_PERMISSIONS: list[str] = [ + HEALTHCARE_VIEW, + HEALTHCARE_MANAGE, + CLINICS_VIEW, + CLINICS_CREATE, + CLINICS_UPDATE, + CLINICS_DELETE, + CLINICS_MANAGE, + BRANCHES_VIEW, + BRANCHES_CREATE, + BRANCHES_UPDATE, + BRANCHES_DELETE, + BRANCHES_MANAGE, + DEPARTMENTS_VIEW, + DEPARTMENTS_CREATE, + DEPARTMENTS_UPDATE, + DEPARTMENTS_DELETE, + DEPARTMENTS_MANAGE, + DOCTORS_VIEW, + DOCTORS_CREATE, + DOCTORS_UPDATE, + DOCTORS_DELETE, + DOCTORS_MANAGE, + PATIENTS_VIEW, + PATIENTS_CREATE, + PATIENTS_UPDATE, + PATIENTS_DELETE, + PATIENTS_MANAGE, + EXTERNAL_PROVIDERS_VIEW, + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_MANAGE, + CONFIGURATIONS_VIEW, + CONFIGURATIONS_CREATE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, + APPOINTMENTS_VIEW, + APPOINTMENTS_MANAGE, + SCHEDULES_VIEW, + SCHEDULES_MANAGE, + DOCTOR_PANEL_VIEW, + VISIT_SESSIONS_MANAGE, + VISIT_NOTES_MANAGE, + CLINIC_ADMIN_VIEW, + CLINIC_ADMIN_MANAGE, + CLINIC_SERVICES_VIEW, + CLINIC_SERVICES_MANAGE, + PATIENT_PORTAL_VIEW, + PATIENT_PORTAL_MANAGE, + MEDICAL_RECORDS_VIEW, + MEDICAL_RECORDS_MANAGE, + ENCOUNTERS_MANAGE, + PRESCRIPTIONS_SUBMIT, + PHARMACIES_MANAGE, + PRESCRIPTION_ORDERS_FULFILL, + DELIVERY_INTEGRATION_VIEW, + DELIVERY_INTEGRATION_MANAGE, + DELIVERY_INTEGRATION_SUBMIT, + VISITS_VIEW, + VISITS_MANAGE, + PHARMACY_VIEW, + PHARMACY_MANAGE, + RECORDS_VIEW, + RECORDS_MANAGE, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "healthcare.", + "healthcare.clinics.", + "healthcare.branches.", + "healthcare.departments.", + "healthcare.doctors.", + "healthcare.patients.", + "healthcare.external_providers.", + "healthcare.configurations.", + "healthcare.settings.", + "healthcare.audit.", + "healthcare.appointments.", + "healthcare.schedules.", + "healthcare.doctor_panel.", + "healthcare.visit_sessions.", + "healthcare.visit_notes.", + "healthcare.clinic_admin.", + "healthcare.clinic_services.", + "healthcare.patient_portal.", + "healthcare.medical_records.", + "healthcare.encounters.", + "healthcare.prescriptions.", + "healthcare.pharmacies.", + "healthcare.prescription_orders.", + "healthcare.delivery_integration.", + "healthcare.visits.", + "healthcare.pharmacy.", + "healthcare.records.", +) diff --git a/backend/services/healthcare/app/policies/__init__.py b/backend/services/healthcare/app/policies/__init__.py new file mode 100644 index 0000000..5ee39bc --- /dev/null +++ b/backend/services/healthcare/app/policies/__init__.py @@ -0,0 +1,4 @@ +"""Healthcare domain policies package.""" +from app.policies.appointments import AppointmentLifecyclePolicy + +__all__ = ["AppointmentLifecyclePolicy"] diff --git a/backend/services/healthcare/app/policies/appointments.py b/backend/services/healthcare/app/policies/appointments.py new file mode 100644 index 0000000..7addbbd --- /dev/null +++ b/backend/services/healthcare/app/policies/appointments.py @@ -0,0 +1,13 @@ +"""Appointment lifecycle policies — Phase 13.1.""" +from __future__ import annotations + +from app.models.types import AppointmentLifecycleAction, AppointmentStatus +from app.validators.appointments import ensure_appointment_lifecycle_transition, target_status_for + + +class AppointmentLifecyclePolicy: + def ensure_transition( + self, *, action: AppointmentLifecycleAction, current: AppointmentStatus + ) -> AppointmentStatus: + ensure_appointment_lifecycle_transition(action=action, current=current) + return target_status_for(action) diff --git a/backend/services/healthcare/app/policies/doctor_panel.py b/backend/services/healthcare/app/policies/doctor_panel.py new file mode 100644 index 0000000..057e9a6 --- /dev/null +++ b/backend/services/healthcare/app/policies/doctor_panel.py @@ -0,0 +1,13 @@ +"""Visit session lifecycle policies — Phase 13.2.""" +from __future__ import annotations + +from app.models.types import VisitSessionAction, VisitSessionStatus +from app.validators.doctor_panel import ensure_visit_session_transition, target_status_for + + +class VisitSessionPolicy: + def ensure_transition( + self, *, action: VisitSessionAction, current: VisitSessionStatus + ) -> VisitSessionStatus: + ensure_visit_session_transition(action=action, current=current) + return target_status_for(action) diff --git a/backend/services/healthcare/app/policies/medical_records.py b/backend/services/healthcare/app/policies/medical_records.py new file mode 100644 index 0000000..13d33bf --- /dev/null +++ b/backend/services/healthcare/app/policies/medical_records.py @@ -0,0 +1,25 @@ +"""Medical record access policies — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from shared.exceptions import ForbiddenError + + +class MedicalRecordAccessPolicy: + def ensure_read_access( + self, + *, + access_policy: dict | None, + actor_user_id: str, + doctor_id: UUID | None = None, + ) -> None: + if not access_policy: + return + allowed_doctors = access_policy.get("allowed_doctor_ids") or [] + if allowed_doctors and doctor_id and str(doctor_id) not in allowed_doctors: + if actor_user_id not in (access_policy.get("break_glass_users") or []): + raise ForbiddenError( + "دسترسی به پرونده پزشکی مجاز نیست", + error_code="medical_record_access_denied", + ) diff --git a/backend/services/healthcare/app/policies/pharmacy.py b/backend/services/healthcare/app/policies/pharmacy.py new file mode 100644 index 0000000..d04975b --- /dev/null +++ b/backend/services/healthcare/app/policies/pharmacy.py @@ -0,0 +1,13 @@ +"""Prescription lifecycle policies — Phase 13.6.""" +from __future__ import annotations + +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from app.validators.pharmacy import ensure_prescription_transition, target_prescription_status + + +class PrescriptionLifecyclePolicy: + def ensure_transition( + self, *, action: PrescriptionLifecycleAction, current: PrescriptionOrderStatus + ) -> PrescriptionOrderStatus: + ensure_prescription_transition(action=action, current=current) + return target_prescription_status(action) diff --git a/backend/services/healthcare/app/providers/__init__.py b/backend/services/healthcare/app/providers/__init__.py new file mode 100644 index 0000000..abfcbbb --- /dev/null +++ b/backend/services/healthcare/app/providers/__init__.py @@ -0,0 +1,11 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + StorageProvider, +) diff --git a/backend/services/healthcare/app/providers/contracts.py b/backend/services/healthcare/app/providers/contracts.py new file mode 100644 index 0000000..e22f2f0 --- /dev/null +++ b/backend/services/healthcare/app/providers/contracts.py @@ -0,0 +1,75 @@ +"""Platform capability contracts — interfaces only; no implementations in Healthcare.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Healthcare must not post journals.""" + + async def create_billing_intent( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Healthcare stores external contact refs only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Healthcare must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Healthcare must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class DeliveryProvider(Protocol): + """Contract for Delivery. Healthcare stores delivery job refs only.""" + + async def request_delivery( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... + + +class StorageProvider(Protocol): + """Contract for File Storage. Healthcare stores document refs only.""" + + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Healthcare must not own model inference.""" + + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + """Contract for Identity. Healthcare stores user_id refs only.""" + + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... diff --git a/backend/services/healthcare/app/providers/mocks.py b/backend/services/healthcare/app/providers/mocks.py new file mode 100644 index 0000000..1bdf56a --- /dev/null +++ b/backend/services/healthcare/app/providers/mocks.py @@ -0,0 +1,89 @@ +"""No-op mock implementations of platform provider contracts — Healthcare.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + + +class MockAccountingProvider: + async def create_billing_intent( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "reference": reference, "provider": "mock_accounting"} + + +class MockCRMProvider: + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: + return {"ok": True, "contact_ref": contact_ref, "provider": "mock_crm"} + + +class MockLoyaltyProvider: + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: + return {"ok": True, "member_ref": member_ref, "provider": "mock_loyalty"} + + +class MockCommunicationProvider: + sent: list[dict[str, Any]] = [] + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: + MockCommunicationProvider.sent.append( + { + "tenant_id": tenant_id, + "channel": channel, + "template_key": template_key, + "to": to, + "payload": payload, + } + ) + + @classmethod + def reset(cls) -> None: + cls.sent = [] + + +class MockDeliveryProvider: + jobs: list[dict[str, Any]] = [] + + async def request_delivery( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: + ref = f"mock-delivery-{len(self.jobs) + 1}" + self.jobs.append({"tenant_id": tenant_id, "request": request, "ref": ref}) + return {"ok": True, "delivery_ref": ref, "provider": "mock_delivery"} + + @classmethod + def reset(cls) -> None: + cls.jobs = [] + + +class MockStorageProvider: + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: + return {"ok": True, "file_ref": file_ref, "provider": "mock_storage"} + + +class MockAIProvider: + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "task": task, "provider": "mock_ai"} + + +class MockIdentityProvider: + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: + return {"ok": True, "user_ref": user_ref, "provider": "mock_identity"} diff --git a/backend/services/healthcare/app/queries/__init__.py b/backend/services/healthcare/app/queries/__init__.py new file mode 100644 index 0000000..195a551 --- /dev/null +++ b/backend/services/healthcare/app/queries/__init__.py @@ -0,0 +1,4 @@ +"""Profile queries package.""" +from app.queries.profiles import ProfileQueries + +__all__ = ["ProfileQueries"] diff --git a/backend/services/healthcare/app/queries/appointments.py b/backend/services/healthcare/app/queries/appointments.py new file mode 100644 index 0000000..2df4991 --- /dev/null +++ b/backend/services/healthcare/app/queries/appointments.py @@ -0,0 +1,49 @@ +"""Appointment query handlers — Phase 13.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.appointments import ( + AppointmentService, + AppointmentTypeService, + AvailabilityWindowService, + ScheduleService, + WaitingListService, +) +from app.specifications.appointments import AppointmentListSpec + + +class AppointmentQueries: + def __init__(self, session: AsyncSession) -> None: + self.schedules = ScheduleService(session) + self.availability = AvailabilityWindowService(session) + self.types = AppointmentTypeService(session) + self.appointments = AppointmentService(session) + self.waiting_list = WaitingListService(session) + + async def list_schedules(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.schedules.list(tenant_id, offset=offset, limit=limit) + + async def list_availability(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.availability.list(tenant_id, offset=offset, limit=limit) + + async def list_types(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.types.list(tenant_id, offset=offset, limit=limit) + + async def get_appointment(self, tenant_id: UUID, appointment_id: UUID): + return await self.appointments.get(tenant_id, appointment_id) + + async def list_appointments( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ): + return await self.appointments.list(tenant_id, offset=offset, limit=limit, spec=spec) + + async def list_waiting_list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.waiting_list.list(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/queries/doctor_panel.py b/backend/services/healthcare/app/queries/doctor_panel.py new file mode 100644 index 0000000..76a97d4 --- /dev/null +++ b/backend/services/healthcare/app/queries/doctor_panel.py @@ -0,0 +1,37 @@ +"""Doctor panel query handlers — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.doctor_panel import VisitNoteService, VisitSessionService +from app.specifications.doctor_panel import VisitSessionListSpec + + +class DoctorPanelQueries: + def __init__(self, session: AsyncSession) -> None: + self.sessions = VisitSessionService(session) + self.notes = VisitNoteService(session) + + async def get_session(self, tenant_id: UUID, visit_session_id: UUID): + return await self.sessions.get(tenant_id, visit_session_id) + + async def list_sessions( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ): + return await self.sessions.list(tenant_id, offset=offset, limit=limit, spec=spec) + + async def dashboard(self, tenant_id: UUID, *, doctor_id: UUID): + return await self.sessions.dashboard(tenant_id, doctor_id=doctor_id) + + async def queue(self, tenant_id: UUID, *, doctor_id: UUID): + return await self.sessions.queue(tenant_id, doctor_id=doctor_id) + + async def list_notes(self, tenant_id: UUID, visit_session_id: UUID): + return await self.notes.list_by_session(tenant_id, visit_session_id) diff --git a/backend/services/healthcare/app/queries/profiles.py b/backend/services/healthcare/app/queries/profiles.py new file mode 100644 index 0000000..4294720 --- /dev/null +++ b/backend/services/healthcare/app/queries/profiles.py @@ -0,0 +1,26 @@ +"""Profile query handlers — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.profiles import DoctorService, PatientService + + +class ProfileQueries: + def __init__(self, session: AsyncSession) -> None: + self.doctors = DoctorService(session) + self.patients = PatientService(session) + + async def get_doctor(self, tenant_id: UUID, doctor_id: UUID): + return await self.doctors.get(tenant_id, doctor_id) + + async def list_doctors(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.doctors.list(tenant_id, offset=offset, limit=limit) + + async def get_patient(self, tenant_id: UUID, patient_id: UUID): + return await self.patients.get(tenant_id, patient_id) + + async def list_patients(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.patients.list(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/repositories/appointments.py b/backend/services/healthcare/app/repositories/appointments.py new file mode 100644 index 0000000..6386dc2 --- /dev/null +++ b/backend/services/healthcare/app/repositories/appointments.py @@ -0,0 +1,153 @@ +"""Appointment repositories — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import and_, func, or_, select + +from app.models.appointments import ( + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.types import AppointmentStatus +from app.repositories.base import TenantBaseRepository +from app.specifications.appointments import AppointmentListSpec + + +class ScheduleRepository(TenantBaseRepository[Schedule]): + model = Schedule + + async def get_by_code(self, tenant_id: UUID, code: str) -> Schedule | None: + stmt = select(Schedule).where( + Schedule.tenant_id == tenant_id, + Schedule.code == code, + Schedule.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AvailabilityWindowRepository(TenantBaseRepository[AvailabilityWindow]): + model = AvailabilityWindow + + async def get_by_code(self, tenant_id: UUID, code: str) -> AvailabilityWindow | None: + stmt = select(AvailabilityWindow).where( + AvailabilityWindow.tenant_id == tenant_id, + AvailabilityWindow.code == code, + AvailabilityWindow.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AppointmentTypeRepository(TenantBaseRepository[AppointmentType]): + model = AppointmentType + + async def get_by_code(self, tenant_id: UUID, code: str) -> AppointmentType | None: + stmt = select(AppointmentType).where( + AppointmentType.tenant_id == tenant_id, + AppointmentType.code == code, + AppointmentType.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AppointmentRepository(TenantBaseRepository[Appointment]): + model = Appointment + + async def get_by_code(self, tenant_id: UUID, code: str) -> Appointment | None: + stmt = select(Appointment).where( + Appointment.tenant_id == tenant_id, + Appointment.code == code, + Appointment.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def find_conflicts( + self, + tenant_id: UUID, + *, + doctor_id: UUID, + scheduled_start: datetime, + scheduled_end: datetime, + exclude_id: UUID | None = None, + ) -> list[Appointment]: + clauses = [ + Appointment.tenant_id == tenant_id, + Appointment.doctor_id == doctor_id, + Appointment.is_deleted.is_(False), + Appointment.status.not_in( + [AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW] + ), + Appointment.scheduled_start < scheduled_end, + Appointment.scheduled_end > scheduled_start, + ] + if exclude_id is not None: + clauses.append(Appointment.id != exclude_id) + stmt = select(Appointment).where(and_(*clauses)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + def _filter_clauses(self, tenant_id: UUID, spec: AppointmentListSpec | None): + clauses = [ + Appointment.tenant_id == tenant_id, + Appointment.is_deleted.is_(False), + ] + if spec is None: + return clauses + if spec.status is not None: + clauses.append(Appointment.status == spec.status) + if spec.doctor_id is not None: + clauses.append(Appointment.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(Appointment.clinic_id == spec.clinic_id) + if spec.patient_id is not None: + clauses.append(Appointment.patient_id == spec.patient_id) + if spec.date_from is not None: + clauses.append(Appointment.scheduled_start >= spec.date_from) + if spec.date_to is not None: + clauses.append(Appointment.scheduled_start <= spec.date_to) + if spec.q: + pattern = f"%{spec.q}%" + clauses.append(or_(Appointment.code.ilike(pattern), Appointment.notes.ilike(pattern))) + return clauses + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr(Appointment, spec.sort_by if spec else "scheduled_start", Appointment.scheduled_start) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = select(Appointment).where(and_(*clauses)).order_by(order).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_filtered(self, tenant_id: UUID, *, spec: AppointmentListSpec | None = None) -> int: + clauses = self._filter_clauses(tenant_id, spec) + stmt = select(func.count()).select_from(Appointment).where(and_(*clauses)) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class WaitingListEntryRepository(TenantBaseRepository[WaitingListEntry]): + model = WaitingListEntry + + async def get_by_code(self, tenant_id: UUID, code: str) -> WaitingListEntry | None: + stmt = select(WaitingListEntry).where( + WaitingListEntry.tenant_id == tenant_id, + WaitingListEntry.code == code, + WaitingListEntry.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/healthcare/app/repositories/base.py b/backend/services/healthcare/app/repositories/base.py new file mode 100644 index 0000000..0c6dd9d --- /dev/null +++ b/backend/services/healthcare/app/repositories/base.py @@ -0,0 +1,78 @@ +"""Tenant-aware base repository with soft-delete helpers.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Generic, Sequence, TypeVar +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Base + +ModelT = TypeVar("ModelT", bound=Base) + + +class TenantBaseRepository(Generic[ModelT]): + model: type[ModelT] + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None: + clauses = [ + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_including_deleted( + self, tenant_id: UUID, entity_id: UUID + ) -> ModelT | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def add(self, entity: ModelT) -> ModelT: + self.session.add(entity) + await self.session.flush() + return entity + + async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None: + entity.is_deleted = True # type: ignore[attr-defined] + entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined] + if deleted_by is not None and hasattr(entity, "deleted_by"): + entity.deleted_by = deleted_by # type: ignore[attr-defined] + await self.session.flush() + + async def restore(self, entity: ModelT) -> None: + entity.is_deleted = False # type: ignore[attr-defined] + entity.deleted_at = None # type: ignore[attr-defined] + if hasattr(entity, "deleted_by"): + entity.deleted_by = None # type: ignore[attr-defined] + await self.session.flush() + + async def list_by_tenant( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ModelT]: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_tenant(self, tenant_id: UUID) -> int: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(func.count()).select_from(self.model).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/healthcare/app/repositories/clinic_management.py b/backend/services/healthcare/app/repositories/clinic_management.py new file mode 100644 index 0000000..dfb2f3c --- /dev/null +++ b/backend/services/healthcare/app/repositories/clinic_management.py @@ -0,0 +1,62 @@ +"""Clinic management repositories — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.clinic_management import ( + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.repositories.base import TenantBaseRepository + + +class ClinicServiceRepository(TenantBaseRepository[ClinicService]): + model = ClinicService + + async def get_by_code(self, tenant_id: UUID, code: str) -> ClinicService | None: + stmt = select(ClinicService).where( + ClinicService.tenant_id == tenant_id, + ClinicService.code == code, + ClinicService.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class StaffAssignmentRepository(TenantBaseRepository[StaffAssignment]): + model = StaffAssignment + + async def get_by_code(self, tenant_id: UUID, code: str) -> StaffAssignment | None: + stmt = select(StaffAssignment).where( + StaffAssignment.tenant_id == tenant_id, + StaffAssignment.code == code, + StaffAssignment.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class ClinicPolicyRepository(TenantBaseRepository[ClinicPolicy]): + model = ClinicPolicy + + async def get_by_code(self, tenant_id: UUID, code: str) -> ClinicPolicy | None: + stmt = select(ClinicPolicy).where( + ClinicPolicy.tenant_id == tenant_id, + ClinicPolicy.code == code, + ClinicPolicy.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class OperatingHoursPolicyRepository(TenantBaseRepository[OperatingHoursPolicy]): + model = OperatingHoursPolicy + + async def get_by_code(self, tenant_id: UUID, code: str) -> OperatingHoursPolicy | None: + stmt = select(OperatingHoursPolicy).where( + OperatingHoursPolicy.tenant_id == tenant_id, + OperatingHoursPolicy.code == code, + OperatingHoursPolicy.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() diff --git a/backend/services/healthcare/app/repositories/delivery_integration.py b/backend/services/healthcare/app/repositories/delivery_integration.py new file mode 100644 index 0000000..da7416a --- /dev/null +++ b/backend/services/healthcare/app/repositories/delivery_integration.py @@ -0,0 +1,65 @@ +"""Delivery integration repositories — Phase 13.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.delivery_integration import ( + DeliveryIntegrationRegistration, + DeliveryJobIntent, + DeliveryJobStatusSnapshot, +) +from app.repositories.base import TenantBaseRepository + + +class DeliveryIntegrationRepository(TenantBaseRepository[DeliveryIntegrationRegistration]): + model = DeliveryIntegrationRegistration + + async def get_by_code(self, tenant_id: UUID, code: str) -> DeliveryIntegrationRegistration | None: + stmt = select(DeliveryIntegrationRegistration).where( + DeliveryIntegrationRegistration.tenant_id == tenant_id, + DeliveryIntegrationRegistration.code == code, + DeliveryIntegrationRegistration.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryJobIntentRepository(TenantBaseRepository[DeliveryJobIntent]): + model = DeliveryJobIntent + + async def get_by_code(self, tenant_id: UUID, code: str) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.code == code, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str + ) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.idempotency_key == idempotency_key, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_delivery_ref( + self, tenant_id: UUID, delivery_job_ref: str + ) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.delivery_job_ref == delivery_job_ref, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryJobStatusSnapshotRepository(TenantBaseRepository[DeliveryJobStatusSnapshot]): + model = DeliveryJobStatusSnapshot diff --git a/backend/services/healthcare/app/repositories/doctor_panel.py b/backend/services/healthcare/app/repositories/doctor_panel.py new file mode 100644 index 0000000..b203603 --- /dev/null +++ b/backend/services/healthcare/app/repositories/doctor_panel.py @@ -0,0 +1,119 @@ +"""Doctor panel repositories — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import and_, func, select + +from app.models.doctor_panel import VisitNote, VisitSession +from app.models.types import VisitSessionStatus +from app.repositories.base import TenantBaseRepository +from app.specifications.doctor_panel import VisitSessionListSpec + + +class VisitSessionRepository(TenantBaseRepository[VisitSession]): + model = VisitSession + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.code == code, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_appointment( + self, tenant_id: UUID, appointment_id: UUID + ) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.appointment_id == appointment_id, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + def _filter_clauses(self, tenant_id: UUID, spec: VisitSessionListSpec | None): + clauses = [VisitSession.tenant_id == tenant_id, VisitSession.is_deleted.is_(False)] + if spec is None: + return clauses + if spec.doctor_id is not None: + clauses.append(VisitSession.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(VisitSession.clinic_id == spec.clinic_id) + if spec.status is not None: + clauses.append(VisitSession.status == spec.status) + return clauses + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr( + VisitSession, + spec.sort_by if spec else "queue_position", + VisitSession.queue_position, + ) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = ( + select(VisitSession) + .where(and_(*clauses)) + .order_by(order) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_status( + self, tenant_id: UUID, *, doctor_id: UUID, status: VisitSessionStatus + ) -> int: + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == status, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_completed_today(self, tenant_id: UUID, *, doctor_id: UUID) -> int: + today = datetime.now(timezone.utc).date() + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == VisitSessionStatus.COMPLETED, + VisitSession.is_deleted.is_(False), + func.date(VisitSession.finished_at) == today, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class VisitNoteRepository(TenantBaseRepository[VisitNote]): + model = VisitNote + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitNote | None: + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.code == code, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID): + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.visit_session_id == visit_session_id, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/foundation.py b/backend/services/healthcare/app/repositories/foundation.py new file mode 100644 index 0000000..15ce843 --- /dev/null +++ b/backend/services/healthcare/app/repositories/foundation.py @@ -0,0 +1,150 @@ +"""Healthcare foundation repositories.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + Branch, + Clinic, + Department, + ExternalProviderConfig, + HealthcareAuditLog, + HealthcareConfiguration, + HealthcarePermission, + HealthcareRole, + HealthcareSetting, +) +from app.repositories.base import TenantBaseRepository + + +class ClinicRepository(TenantBaseRepository[Clinic]): + model = Clinic + + async def get_by_code(self, tenant_id: UUID, code: str) -> Clinic | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class BranchRepository(TenantBaseRepository[Branch]): + model = Branch + + async def list_by_clinic( + self, tenant_id: UUID, clinic_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[Branch]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.clinic_id == clinic_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DepartmentRepository(TenantBaseRepository[Department]): + model = Department + + async def get_by_code( + self, tenant_id: UUID, clinic_id: UUID, code: str + ) -> Department | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.clinic_id == clinic_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareRoleRepository(TenantBaseRepository[HealthcareRole]): + model = HealthcareRole + + +class HealthcarePermissionRepository(TenantBaseRepository[HealthcarePermission]): + model = HealthcarePermission + + +class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]): + model = ExternalProviderConfig + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> ExternalProviderConfig | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareConfigurationRepository(TenantBaseRepository[HealthcareConfiguration]): + model = HealthcareConfiguration + + +class HealthcareSettingRepository(TenantBaseRepository[HealthcareSetting]): + model = HealthcareSetting + + async def get_by_key( + self, + tenant_id: UUID, + key: str, + *, + clinic_id: UUID | None = None, + branch_id: UUID | None = None, + ) -> HealthcareSetting | None: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.key == key, + self.model.is_deleted.is_(False), + ] + if clinic_id is None: + clauses.append(self.model.clinic_id.is_(None)) + else: + clauses.append(self.model.clinic_id == clinic_id) + if branch_id is None: + clauses.append(self.model.branch_id.is_(None)) + else: + clauses.append(self.model.branch_id == branch_id) + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareAuditLogRepository(TenantBaseRepository[HealthcareAuditLog]): + model = HealthcareAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ) -> Sequence[HealthcareAuditLog]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.entity_type == entity_type, + self.model.entity_id == entity_id, + ) + .order_by(self.model.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/medical_records.py b/backend/services/healthcare/app/repositories/medical_records.py new file mode 100644 index 0000000..d4acddc --- /dev/null +++ b/backend/services/healthcare/app/repositories/medical_records.py @@ -0,0 +1,116 @@ +"""Medical record repositories — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.medical_records import ( + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.repositories.base import TenantBaseRepository + + +class MedicalRecordRepository(TenantBaseRepository[MedicalRecord]): + model = MedicalRecord + + async def get_by_code(self, tenant_id: UUID, code: str) -> MedicalRecord | None: + stmt = select(MedicalRecord).where( + MedicalRecord.tenant_id == tenant_id, + MedicalRecord.code == code, + MedicalRecord.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class EncounterRepository(TenantBaseRepository[Encounter]): + model = Encounter + + async def get_by_code(self, tenant_id: UUID, code: str) -> Encounter | None: + stmt = select(Encounter).where( + Encounter.tenant_id == tenant_id, + Encounter.code == code, + Encounter.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Encounter).where( + Encounter.tenant_id == tenant_id, + Encounter.medical_record_id == medical_record_id, + Encounter.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ConditionRepository(TenantBaseRepository[Condition]): + model = Condition + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Condition).where( + Condition.tenant_id == tenant_id, + Condition.medical_record_id == medical_record_id, + Condition.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class AllergyRepository(TenantBaseRepository[Allergy]): + model = Allergy + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Allergy).where( + Allergy.tenant_id == tenant_id, + Allergy.medical_record_id == medical_record_id, + Allergy.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MedicationStatementRepository(TenantBaseRepository[MedicationStatement]): + model = MedicationStatement + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(MedicationStatement).where( + MedicationStatement.tenant_id == tenant_id, + MedicationStatement.medical_record_id == medical_record_id, + MedicationStatement.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ImmunizationRecordRepository(TenantBaseRepository[ImmunizationRecord]): + model = ImmunizationRecord + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(ImmunizationRecord).where( + ImmunizationRecord.tenant_id == tenant_id, + ImmunizationRecord.medical_record_id == medical_record_id, + ImmunizationRecord.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MedicalRecordAccessLogRepository(TenantBaseRepository[MedicalRecordAccessLog]): + model = MedicalRecordAccessLog + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(MedicalRecordAccessLog).where( + MedicalRecordAccessLog.tenant_id == tenant_id, + MedicalRecordAccessLog.medical_record_id == medical_record_id, + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/patient_portal.py b/backend/services/healthcare/app/repositories/patient_portal.py new file mode 100644 index 0000000..4b391b4 --- /dev/null +++ b/backend/services/healthcare/app/repositories/patient_portal.py @@ -0,0 +1,74 @@ +"""Patient portal repositories — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.patient_portal import ( + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.repositories.base import TenantBaseRepository + + +class PatientPortalProfileRepository(TenantBaseRepository[PatientPortalProfile]): + model = PatientPortalProfile + + async def get_by_patient(self, tenant_id: UUID, patient_id: UUID) -> PatientPortalProfile | None: + stmt = select(PatientPortalProfile).where( + PatientPortalProfile.tenant_id == tenant_id, + PatientPortalProfile.patient_id == patient_id, + PatientPortalProfile.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PatientDocumentRefRepository(TenantBaseRepository[PatientDocumentRef]): + model = PatientDocumentRef + + async def get_by_code(self, tenant_id: UUID, code: str) -> PatientDocumentRef | None: + stmt = select(PatientDocumentRef).where( + PatientDocumentRef.tenant_id == tenant_id, + PatientDocumentRef.code == code, + PatientDocumentRef.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient(self, tenant_id: UUID, patient_id: UUID): + stmt = select(PatientDocumentRef).where( + PatientDocumentRef.tenant_id == tenant_id, + PatientDocumentRef.patient_id == patient_id, + PatientDocumentRef.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class PatientNotificationPreferenceRepository(TenantBaseRepository[PatientNotificationPreference]): + model = PatientNotificationPreference + + async def get_pref( + self, tenant_id: UUID, patient_id: UUID, channel: str, template_key: str + ) -> PatientNotificationPreference | None: + stmt = select(PatientNotificationPreference).where( + PatientNotificationPreference.tenant_id == tenant_id, + PatientNotificationPreference.patient_id == patient_id, + PatientNotificationPreference.channel == channel, + PatientNotificationPreference.template_key == template_key, + PatientNotificationPreference.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient(self, tenant_id: UUID, patient_id: UUID): + stmt = select(PatientNotificationPreference).where( + PatientNotificationPreference.tenant_id == tenant_id, + PatientNotificationPreference.patient_id == patient_id, + PatientNotificationPreference.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/pharmacy.py b/backend/services/healthcare/app/repositories/pharmacy.py new file mode 100644 index 0000000..07f1305 --- /dev/null +++ b/backend/services/healthcare/app/repositories/pharmacy.py @@ -0,0 +1,52 @@ +"""Pharmacy repositories — Phase 13.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.pharmacy import Pharmacy, PrescriptionLine, PrescriptionOrder, PrescriptionStatusHistory +from app.repositories.base import TenantBaseRepository + + +class PharmacyRepository(TenantBaseRepository[Pharmacy]): + model = Pharmacy + + async def get_by_code(self, tenant_id: UUID, code: str) -> Pharmacy | None: + stmt = select(Pharmacy).where( + Pharmacy.tenant_id == tenant_id, + Pharmacy.code == code, + Pharmacy.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PrescriptionOrderRepository(TenantBaseRepository[PrescriptionOrder]): + model = PrescriptionOrder + + async def get_by_code(self, tenant_id: UUID, code: str) -> PrescriptionOrder | None: + stmt = select(PrescriptionOrder).where( + PrescriptionOrder.tenant_id == tenant_id, + PrescriptionOrder.code == code, + PrescriptionOrder.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PrescriptionLineRepository(TenantBaseRepository[PrescriptionLine]): + model = PrescriptionLine + + async def list_by_order(self, tenant_id: UUID, prescription_order_id: UUID): + stmt = select(PrescriptionLine).where( + PrescriptionLine.tenant_id == tenant_id, + PrescriptionLine.prescription_order_id == prescription_order_id, + PrescriptionLine.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class PrescriptionStatusHistoryRepository(TenantBaseRepository[PrescriptionStatusHistory]): + model = PrescriptionStatusHistory diff --git a/backend/services/healthcare/app/repositories/profiles.py b/backend/services/healthcare/app/repositories/profiles.py new file mode 100644 index 0000000..69b6f0f --- /dev/null +++ b/backend/services/healthcare/app/repositories/profiles.py @@ -0,0 +1,53 @@ +"""Doctor and Patient repositories.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.profiles import Doctor, Patient +from app.repositories.base import TenantBaseRepository + + +class DoctorRepository(TenantBaseRepository[Doctor]): + model = Doctor + + async def get_by_code(self, tenant_id: UUID, code: str) -> Doctor | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, tenant_id: UUID, user_id: str) -> Doctor | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.user_id == user_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PatientRepository(TenantBaseRepository[Patient]): + model = Patient + + async def get_by_code(self, tenant_id: UUID, code: str) -> Patient | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, tenant_id: UUID, user_id: str) -> Patient | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.user_id == user_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/healthcare/app/schemas/appointments.py b/backend/services/healthcare/app/schemas/appointments.py new file mode 100644 index 0000000..2881517 --- /dev/null +++ b/backend/services/healthcare/app/schemas/appointments.py @@ -0,0 +1,182 @@ +"""Appointment DTOs — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime, time +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import AppointmentStatus, DayOfWeek, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ScheduleCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + department_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + day_of_week: DayOfWeek + start_time: time + end_time: time + slot_duration_minutes: int = Field(default=30, ge=5, le=480) + is_active: bool = True + metadata_json: dict | None = None + + +class ScheduleRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + department_id: UUID | None + code: str + name: str + day_of_week: DayOfWeek + start_time: time + end_time: time + slot_duration_minutes: int + is_active: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AvailabilityWindowCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + starts_at: datetime + ends_at: datetime + is_bookable: bool = True + metadata_json: dict | None = None + + +class AvailabilityWindowRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + code: str + starts_at: datetime + ends_at: datetime + is_bookable: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentTypeCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + default_duration_minutes: int = Field(default=30, ge=5, le=480) + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class AppointmentTypeRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + description: str | None + default_duration_minutes: int + status: LifecycleStatus + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + department_id: UUID | None = None + appointment_type_id: UUID | None = None + code: str = Field(max_length=50) + scheduled_start: datetime + scheduled_end: datetime + notes: str | None = None + metadata_json: dict | None = None + + +class AppointmentReschedule(BaseModel): + model_config = _FORBID + scheduled_start: datetime + scheduled_end: datetime + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class AppointmentLifecycleRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class AppointmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + department_id: UUID | None + appointment_type_id: UUID | None + code: str + status: AppointmentStatus + scheduled_start: datetime + scheduled_end: datetime + notes: str | None + status_reason: str | None + status_changed_at: datetime | None + reminder_scheduled: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentListResponse(BaseModel): + items: list[AppointmentRead] + total: int + page: int + page_size: int + + +class WaitingListEntryCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID | None = None + patient_id: UUID + appointment_type_id: UUID | None = None + code: str = Field(max_length=50) + preferred_date: str | None = Field(default=None, max_length=32) + notes: str | None = None + priority: int = Field(default=100, ge=1, le=1000) + + +class WaitingListEntryRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID | None + patient_id: UUID + appointment_type_id: UUID | None + code: str + preferred_date: str | None + notes: str | None + priority: int + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/clinic_management.py b/backend/services/healthcare/app/schemas/clinic_management.py new file mode 100644 index 0000000..fa7abde --- /dev/null +++ b/backend/services/healthcare/app/schemas/clinic_management.py @@ -0,0 +1,126 @@ +"""Clinic management DTOs — Phase 13.3.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ClinicPolicyKind, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ClinicServiceCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + department_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + duration_minutes: int = Field(default=30, ge=5, le=480) + pricing_ref: str | None = Field(default=None, max_length=100) + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class ClinicServiceRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + department_id: UUID | None + code: str + name: str + description: str | None + duration_minutes: int + pricing_ref: str | None + status: LifecycleStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class StaffAssignmentCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + department_id: UUID | None = None + doctor_id: UUID + code: str = Field(max_length=50) + role_code: str = Field(max_length=50) + is_primary: bool = False + metadata_json: dict | None = None + + +class StaffAssignmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + department_id: UUID | None + doctor_id: UUID + code: str + role_code: str + is_primary: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ClinicPolicyCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + kind: ClinicPolicyKind + booking_lead_time_hours: int = Field(default=24, ge=0, le=720) + cancellation_window_hours: int = Field(default=12, ge=0, le=720) + privacy_flags: dict | None = None + rules: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class ClinicPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + kind: ClinicPolicyKind + booking_lead_time_hours: int + cancellation_window_hours: int + privacy_flags: dict | None + rules: dict | None + status: LifecycleStatus + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class OperatingHoursPolicyCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + weekly_hours: dict + exceptions: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + + +class OperatingHoursPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + name: str + weekly_hours: dict + exceptions: dict | None + timezone: str + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/common.py b/backend/services/healthcare/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/healthcare/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/healthcare/app/schemas/delivery_integration.py b/backend/services/healthcare/app/schemas/delivery_integration.py new file mode 100644 index 0000000..c886c68 --- /dev/null +++ b/backend/services/healthcare/app/schemas/delivery_integration.py @@ -0,0 +1,77 @@ +"""Delivery integration DTOs — Phase 13.7.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import DeliveryJobStatus, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DeliveryIntegrationCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID | None = None + pharmacy_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + delivery_merchant_ref: str = Field(max_length=255) + webhook_secret_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + config: dict | None = None + + +class DeliveryIntegrationRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + pharmacy_id: UUID | None + code: str + name: str + delivery_merchant_ref: str + webhook_secret_ref: str | None + status: LifecycleStatus + config: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DeliveryJobIntentCreate(BaseModel): + model_config = _FORBID + registration_id: UUID + idempotency_key: str = Field(max_length=100) + code: str = Field(max_length=50) + pickup_address: dict | None = None + dropoff_address: dict | None = None + metadata_json: dict | None = None + + +class DeliveryJobIntentRead(ORMBase): + id: UUID + tenant_id: UUID + registration_id: UUID + prescription_order_id: UUID + code: str + delivery_job_ref: str | None + status: DeliveryJobStatus + idempotency_key: str + pickup_address: dict | None + dropoff_address: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DeliveryStatusWebhook(BaseModel): + model_config = _FORBID + delivery_job_ref: str = Field(max_length=255) + status: DeliveryJobStatus + external_status: str | None = Field(default=None, max_length=100) + payload: dict | None = None diff --git a/backend/services/healthcare/app/schemas/doctor_panel.py b/backend/services/healthcare/app/schemas/doctor_panel.py new file mode 100644 index 0000000..815208a --- /dev/null +++ b/backend/services/healthcare/app/schemas/doctor_panel.py @@ -0,0 +1,98 @@ +"""Doctor panel DTOs — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import VisitSessionStatus +from app.schemas.appointments import AppointmentRead +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class VisitSessionCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str = Field(max_length=50) + queue_position: int = Field(default=100, ge=1, le=10000) + metadata_json: dict | None = None + + +class VisitSessionActionRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class VisitSessionRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str + status: VisitSessionStatus + queue_position: int + started_at: datetime | None + finished_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class VisitNoteCreate(BaseModel): + model_config = _FORBID + visit_session_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class VisitNoteUpdate(BaseModel): + model_config = _FORBID + title: str | None = Field(default=None, max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + version: int = Field(ge=1) + metadata_json: dict | None = None + + +class VisitNoteRead(ORMBase): + id: UUID + tenant_id: UUID + visit_session_id: UUID + doctor_id: UUID + code: str + title: str + body: str | None + storage_file_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DoctorDashboardRead(BaseModel): + doctor_id: UUID + queued_count: int + in_progress_count: int + completed_today_count: int + upcoming_appointments: list[AppointmentRead] + + +class DoctorQueueRead(BaseModel): + doctor_id: UUID + items: list[VisitSessionRead] diff --git a/backend/services/healthcare/app/schemas/foundation.py b/backend/services/healthcare/app/schemas/foundation.py new file mode 100644 index 0000000..95a3cf8 --- /dev/null +++ b/backend/services/healthcare/app/schemas/foundation.py @@ -0,0 +1,284 @@ +"""Healthcare foundation DTOs — Phase 13.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import LifecycleStatus, ProviderKind, ProviderStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ClinicCreate(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class ClinicUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int = Field(ge=1) + + +class ClinicRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BranchCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + address: dict | None + timezone: str | None + working_hours: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DepartmentCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + specialty: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class DepartmentUpdate(BaseModel): + model_config = _FORBID + + branch_id: UUID | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + specialty: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class DepartmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + specialty: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExternalProviderConfigCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + provider_kind: ProviderKind = ProviderKind.CUSTOM + adapter_key: str = Field(max_length=100) + status: ProviderStatus = ProviderStatus.REGISTERED + priority: int = Field(default=100, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + + +class ExternalProviderConfigUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + provider_kind: ProviderKind | None = None + adapter_key: str | None = Field(default=None, max_length=100) + status: ProviderStatus | None = None + priority: int | None = Field(default=None, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class ExternalProviderConfigRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + code: str + name: str + provider_kind: ProviderKind + adapter_key: str + status: ProviderStatus + priority: int + credentials_ref: str | None + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareConfigurationCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + working_hours: dict | None = None + clinical_policies: dict | None = None + privacy_policies: dict | None = None + custom_fields: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + + +class HealthcareConfigurationUpdate(BaseModel): + model_config = _FORBID + + branch_id: UUID | None = None + working_hours: dict | None = None + clinical_policies: dict | None = None + privacy_policies: dict | None = None + custom_fields: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + version: int = Field(ge=1) + + +class HealthcareConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + working_hours: dict | None + clinical_policies: dict | None + privacy_policies: dict | None + custom_fields: dict | None + timezone: str + language: str + currency_code: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareSettingUpsert(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + branch_id: UUID | None = None + key: str = Field(max_length=100) + value: dict | None = None + description: str | None = None + + +class HealthcareSettingRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + branch_id: UUID | None + key: str + value: dict | None + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/healthcare/app/schemas/medical_records.py b/backend/services/healthcare/app/schemas/medical_records.py new file mode 100644 index 0000000..a72857b --- /dev/null +++ b/backend/services/healthcare/app/schemas/medical_records.py @@ -0,0 +1,168 @@ +"""Medical record DTOs — Phase 13.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import EncounterStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class MedicalRecordCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + patient_id: UUID + code: str = Field(max_length=50) + access_policy: dict | None = None + metadata_json: dict | None = None + + +class MedicalRecordRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + patient_id: UUID + code: str + access_policy: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class EncounterCreate(BaseModel): + model_config = _FORBID + medical_record_id: UUID + visit_session_id: UUID | None = None + appointment_id: UUID | None = None + doctor_id: UUID + code: str = Field(max_length=50) + chief_complaint: str | None = None + clinical_summary: str | None = None + metadata_json: dict | None = None + + +class EncounterUpdate(BaseModel): + model_config = _FORBID + chief_complaint: str | None = None + clinical_summary: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class EncounterFinalize(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + addendum: str | None = None + + +class EncounterRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + visit_session_id: UUID | None + appointment_id: UUID | None + doctor_id: UUID + code: str + status: EncounterStatus + chief_complaint: str | None + clinical_summary: str | None + finalized_at: datetime | None + addendum: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ClinicalItemCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + metadata_json: dict | None = None + + +class ConditionCreate(ClinicalItemCreate): + display: str = Field(max_length=255) + clinical_status: str | None = Field(default=None, max_length=50) + + +class AllergyCreate(ClinicalItemCreate): + substance: str = Field(max_length=255) + severity: str | None = Field(default=None, max_length=50) + + +class MedicationCreate(ClinicalItemCreate): + medication_code: str = Field(max_length=100) + display: str = Field(max_length=255) + dosage: str | None = Field(default=None, max_length=100) + + +class ImmunizationCreate(ClinicalItemCreate): + vaccine_code: str = Field(max_length=100) + administered_on: str | None = Field(default=None, max_length=32) + storage_file_ref: str | None = Field(default=None, max_length=255) + + +class ConditionRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + display: str + clinical_status: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AllergyRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + substance: str + severity: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MedicationRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + medication_code: str + display: str + dosage: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ImmunizationRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + vaccine_code: str + administered_on: str | None + storage_file_ref: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MedicalRecordAccessRequest(BaseModel): + model_config = _FORBID + reason: str | None = Field(default=None, max_length=500) diff --git a/backend/services/healthcare/app/schemas/patient_portal.py b/backend/services/healthcare/app/schemas/patient_portal.py new file mode 100644 index 0000000..4cf8182 --- /dev/null +++ b/backend/services/healthcare/app/schemas/patient_portal.py @@ -0,0 +1,99 @@ +"""Patient portal DTOs — Phase 13.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.appointments import AppointmentCreate, AppointmentLifecycleRequest, AppointmentRead +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PatientPortalProfileCreate(BaseModel): + model_config = _FORBID + patient_id: UUID + preferred_clinic_id: UUID | None = None + preferred_language: str = Field(default="fa", max_length=16) + emergency_contact_ref: str | None = Field(default=None, max_length=255) + notification_prefs: dict | None = None + metadata_json: dict | None = None + + +class PatientPortalProfileUpdate(BaseModel): + model_config = _FORBID + preferred_clinic_id: UUID | None = None + preferred_language: str | None = Field(default=None, max_length=16) + emergency_contact_ref: str | None = Field(default=None, max_length=255) + notification_prefs: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PatientPortalProfileRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + preferred_clinic_id: UUID | None + preferred_language: str + emergency_contact_ref: str | None + notification_prefs: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientDocumentRefCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + storage_file_ref: str = Field(max_length=255) + document_type: str | None = Field(default=None, max_length=50) + metadata_json: dict | None = None + + +class PatientDocumentRefRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + code: str + title: str + storage_file_ref: str + document_type: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientNotificationPreferenceUpsert(BaseModel): + model_config = _FORBID + channel: str = Field(max_length=32) + template_key: str = Field(max_length=100) + enabled: bool = True + metadata_json: dict | None = None + + +class PatientNotificationPreferenceRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + channel: str + template_key: str + enabled: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientPortalAppointmentBook(AppointmentCreate): + pass + + +class PatientPortalAppointmentCancel(AppointmentLifecycleRequest): + pass diff --git a/backend/services/healthcare/app/schemas/pharmacy.py b/backend/services/healthcare/app/schemas/pharmacy.py new file mode 100644 index 0000000..1aef53a --- /dev/null +++ b/backend/services/healthcare/app/schemas/pharmacy.py @@ -0,0 +1,102 @@ +"""Pharmacy network DTOs — Phase 13.6.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import LifecycleStatus, PrescriptionOrderStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PharmacyCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + contact: dict | None = None + hours: dict | None = None + service_area_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class PharmacyRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + contact: dict | None + hours: dict | None + service_area_ref: str | None + status: LifecycleStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PrescriptionLineCreate(BaseModel): + model_config = _FORBID + line_no: int = Field(ge=1) + medication_code: str = Field(max_length=100) + display: str = Field(max_length=255) + quantity: int = Field(default=1, ge=1) + dosage_instructions: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class PrescriptionOrderCreate(BaseModel): + model_config = _FORBID + pharmacy_id: UUID + encounter_id: UUID | None = None + visit_session_id: UUID | None = None + patient_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + notes: str | None = None + lines: list[PrescriptionLineCreate] + metadata_json: dict | None = None + + +class PrescriptionLifecycleRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class PrescriptionLineRead(ORMBase): + id: UUID + tenant_id: UUID + prescription_order_id: UUID + line_no: int + medication_code: str + display: str + quantity: int + dosage_instructions: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PrescriptionOrderRead(ORMBase): + id: UUID + tenant_id: UUID + pharmacy_id: UUID + encounter_id: UUID | None + visit_session_id: UUID | None + patient_id: UUID + doctor_id: UUID + code: str + status: PrescriptionOrderStatus + status_changed_at: datetime | None + notes: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/profiles.py b/backend/services/healthcare/app/schemas/profiles.py new file mode 100644 index 0000000..e3efa64 --- /dev/null +++ b/backend/services/healthcare/app/schemas/profiles.py @@ -0,0 +1,109 @@ +"""Doctor and Patient DTOs — Phase 13.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ProfileStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DoctorCreate(BaseModel): + model_config = _FORBID + + user_id: str = Field(max_length=100) + clinic_id: UUID | None = None + department_id: UUID | None = None + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + specialty: str | None = Field(default=None, max_length=100) + license_number: str | None = Field(default=None, max_length=100) + status: ProfileStatus = ProfileStatus.ACTIVE + contact: dict | None = None + metadata_json: dict | None = None + + +class DoctorUpdate(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + department_id: UUID | None = None + display_name: str | None = Field(default=None, max_length=255) + specialty: str | None = Field(default=None, max_length=100) + license_number: str | None = Field(default=None, max_length=100) + status: ProfileStatus | None = None + contact: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class DoctorRead(ORMBase): + id: UUID + tenant_id: UUID + user_id: str + clinic_id: UUID | None + department_id: UUID | None + code: str + display_name: str + specialty: str | None + license_number: str | None + status: ProfileStatus + contact: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PatientCreate(BaseModel): + model_config = _FORBID + + user_id: str = Field(max_length=100) + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + status: ProfileStatus = ProfileStatus.ACTIVE + date_of_birth: str | None = Field(default=None, max_length=32) + gender: str | None = Field(default=None, max_length=32) + contact: dict | None = None + emergency_contact: dict | None = None + metadata_json: dict | None = None + + +class PatientUpdate(BaseModel): + model_config = _FORBID + + display_name: str | None = Field(default=None, max_length=255) + status: ProfileStatus | None = None + date_of_birth: str | None = Field(default=None, max_length=32) + gender: str | None = Field(default=None, max_length=32) + contact: dict | None = None + emergency_contact: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PatientRead(ORMBase): + id: UUID + tenant_id: UUID + user_id: str + code: str + display_name: str + status: ProfileStatus + date_of_birth: str | None + gender: str | None + contact: dict | None + emergency_contact: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/services/appointments.py b/backend/services/healthcare/app/services/appointments.py new file mode 100644 index 0000000..2ba8d64 --- /dev/null +++ b/backend/services/healthcare/app/services/appointments.py @@ -0,0 +1,485 @@ +"""Appointment application services — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.appointments import ( + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.types import AppointmentLifecycleAction, AppointmentStatus, AuditAction +from app.policies.appointments import AppointmentLifecyclePolicy +from app.providers.mocks import MockCommunicationProvider +from app.repositories.appointments import ( + AppointmentRepository, + AppointmentTypeRepository, + AvailabilityWindowRepository, + ScheduleRepository, + WaitingListEntryRepository, +) +from app.repositories.foundation import ClinicRepository, DepartmentRepository +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentReschedule, + AppointmentTypeCreate, + AvailabilityWindowCreate, + ScheduleCreate, + WaitingListEntryCreate, +) +from app.services.audit_service import AuditService +from app.services.foundation import _actor_id, _jsonable_changes +from app.specifications.appointments import AppointmentListSpec +from app.validators import ensure_optimistic_version, validate_code +from app.validators.appointments import validate_schedule_times, validate_time_window +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class ScheduleService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ScheduleRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.departments = DepartmentRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, tenant_id: UUID, body: ScheduleCreate, *, actor: CurrentUser | None = None + ) -> Schedule: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + code = validate_code(body.code) + validate_schedule_times(start_time=body.start_time, end_time=body.end_time) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Schedule( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + department_id=body.department_id, + code=code, + name=body.name, + day_of_week=body.day_of_week, + start_time=body.start_time, + end_time=body.end_time, + slot_duration_minutes=body.slot_duration_minutes, + is_active=body.is_active, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + self.events.publish( + event_type=HealthcareEventType.SCHEDULE_CREATED, + aggregate_type="schedule", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20) -> list[Schedule]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AvailabilityWindowService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = AvailabilityWindowRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: AvailabilityWindowCreate, + *, + actor: CurrentUser | None = None, + ) -> AvailabilityWindow: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + validate_time_window(starts_at=body.starts_at, ends_at=body.ends_at) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = AvailabilityWindow( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + code=code, + starts_at=body.starts_at, + ends_at=body.ends_at, + is_bookable=body.is_bookable, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[AvailabilityWindow]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AppointmentTypeService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = AppointmentTypeRepository(session) + self.clinics = ClinicRepository(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: AppointmentTypeCreate, + *, + actor: CurrentUser | None = None, + ) -> AppointmentType: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = AppointmentType( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=body.name, + description=body.description, + default_duration_minutes=body.default_duration_minutes, + status=body.status, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[AppointmentType]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AppointmentService: + def __init__( + self, + session: AsyncSession, + *, + communication: MockCommunicationProvider | None = None, + ) -> None: + self.session = session + self.repo = AppointmentRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.patients = PatientRepository(session) + self.departments = DepartmentRepository(session) + self.types = AppointmentTypeRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policy = AppointmentLifecyclePolicy() + self.communication = communication or MockCommunicationProvider() + + async def _validate_refs(self, tenant_id: UUID, body: AppointmentCreate) -> None: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + if body.appointment_type_id and await self.types.get(tenant_id, body.appointment_type_id) is None: + raise NotFoundError("نوع نوبت یافت نشد") + + async def _ensure_no_conflict( + self, + tenant_id: UUID, + *, + doctor_id: UUID, + scheduled_start: datetime, + scheduled_end: datetime, + exclude_id: UUID | None = None, + ) -> None: + conflicts = await self.repo.find_conflicts( + tenant_id, + doctor_id=doctor_id, + scheduled_start=scheduled_start, + scheduled_end=scheduled_end, + exclude_id=exclude_id, + ) + if conflicts: + raise AppError( + "تداخل زمانی با نوبت دیگر", + status_code=409, + error_code="appointment_conflict", + details={"conflicting_ids": [str(c.id) for c in conflicts]}, + ) + + async def book( + self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None = None + ) -> Appointment: + validate_time_window(starts_at=body.scheduled_start, ends_at=body.scheduled_end) + await self._validate_refs(tenant_id, body) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + await self._ensure_no_conflict( + tenant_id, + doctor_id=body.doctor_id, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + ) + entity = Appointment( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + department_id=body.department_id, + appointment_type_id=body.appointment_type_id, + code=code, + status=AppointmentStatus.DRAFT, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + notes=body.notes, + metadata_json=body.metadata_json, + status_changed_at=datetime.now(timezone.utc), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.APPOINTMENT_BOOKED, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, appointment_id: UUID) -> Appointment: + entity = await self.repo.get(tenant_id, appointment_id) + if entity is None: + raise NotFoundError("نوبت یافت نشد") + return entity + + async def list( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ) -> tuple[list[Appointment], int]: + items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec)) + total = await self.repo.count_filtered(tenant_id, spec=spec) + return items, total + + async def _apply_lifecycle( + self, + tenant_id: UUID, + appointment_id: UUID, + action: AppointmentLifecycleAction, + body: AppointmentLifecycleRequest, + *, + actor: CurrentUser | None = None, + event_type: HealthcareEventType, + ) -> Appointment: + entity = await self.get(tenant_id, appointment_id) + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + entity.status = new_status + entity.status_reason = body.reason + entity.status_changed_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"action": action.value, "status": new_status.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + if action == AppointmentLifecycleAction.CONFIRM and not entity.reminder_scheduled: + patient = await self.patients.get(tenant_id, entity.patient_id) + to = (patient.contact or {}).get("phone", "unknown") if patient else "unknown" + await self.communication.send( + tenant_id=tenant_id, + channel="sms", + template_key="healthcare.appointment.reminder", + to=to, + payload={"appointment_id": str(entity.id), "code": entity.code}, + ) + entity.reminder_scheduled = True + self.events.publish( + event_type=HealthcareEventType.APPOINTMENT_REMINDER_SCHEDULED, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def confirm( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.CONFIRM, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CONFIRMED, + ) + + async def cancel( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.CANCEL, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CANCELLED, + ) + + async def mark_no_show( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.MARK_NO_SHOW, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_NO_SHOW, + ) + + async def reschedule( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentReschedule, *, actor=None + ) -> Appointment: + entity = await self.get(tenant_id, appointment_id) + ensure_optimistic_version(entity, body.version) + self.policy.ensure_transition( + action=AppointmentLifecycleAction.RESCHEDULE, current=entity.status + ) + validate_time_window(starts_at=body.scheduled_start, ends_at=body.scheduled_end) + await self._ensure_no_conflict( + tenant_id, + doctor_id=entity.doctor_id, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + exclude_id=entity.id, + ) + entity.scheduled_start = body.scheduled_start + entity.scheduled_end = body.scheduled_end + entity.status_reason = body.reason + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.APPOINTMENT_RESCHEDULED, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class WaitingListService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = WaitingListEntryRepository(session) + self.clinics = ClinicRepository(session) + self.patients = PatientRepository(session) + + async def create( + self, + tenant_id: UUID, + body: WaitingListEntryCreate, + *, + actor: CurrentUser | None = None, + ) -> WaitingListEntry: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = WaitingListEntry( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + appointment_type_id=body.appointment_type_id, + code=code, + preferred_date=body.preferred_date, + notes=body.notes, + priority=body.priority, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[WaitingListEntry]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/audit_service.py b/backend/services/healthcare/app/services/audit_service.py new file mode 100644 index 0000000..c2c3208 --- /dev/null +++ b/backend/services/healthcare/app/services/audit_service.py @@ -0,0 +1,51 @@ +"""Audit service — append-only Delivery audit trail.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import HealthcareAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import HealthcareAuditLogRepository + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = HealthcareAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> HealthcareAuditLog: + entry = HealthcareAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=changes, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/healthcare/app/services/clinic_management.py b/backend/services/healthcare/app/services/clinic_management.py new file mode 100644 index 0000000..c71c0d9 --- /dev/null +++ b/backend/services/healthcare/app/services/clinic_management.py @@ -0,0 +1,215 @@ +"""Clinic management application services — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.clinic_management import ( + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.repositories.clinic_management import ( + ClinicPolicyRepository, + ClinicServiceRepository, + OperatingHoursPolicyRepository, + StaffAssignmentRepository, +) +from app.repositories.foundation import BranchRepository, ClinicRepository, DepartmentRepository +from app.repositories.profiles import DoctorRepository +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicServiceCreate, + OperatingHoursPolicyCreate, + StaffAssignmentCreate, +) +from app.services.foundation import _actor_id +from app.validators import validate_code +from app.validators.clinic_management import validate_clinic_policy_windows, validate_weekly_hours +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class ClinicManagementService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.services = ClinicServiceRepository(session) + self.staff = StaffAssignmentRepository(session) + self.policies = ClinicPolicyRepository(session) + self.hours = OperatingHoursPolicyRepository(session) + self.clinics = ClinicRepository(session) + self.branches = BranchRepository(session) + self.departments = DepartmentRepository(session) + self.doctors = DoctorRepository(session) + self.events = get_event_publisher() + + async def create_service( + self, tenant_id: UUID, body: ClinicServiceCreate, *, actor: CurrentUser | None = None + ) -> ClinicService: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + code = validate_code(body.code) + if await self.services.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = ClinicService( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + department_id=body.department_id, + code=code, + name=body.name, + description=body.description, + duration_minutes=body.duration_minutes, + pricing_ref=body.pricing_ref, + status=body.status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.services.add(entity) + except IntegrityError as exc: + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) from exc + self.events.publish( + event_type=HealthcareEventType.CLINIC_SERVICE_CREATED, + aggregate_type="clinic_service", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_staff_assignment( + self, tenant_id: UUID, body: StaffAssignmentCreate, *, actor: CurrentUser | None = None + ) -> StaffAssignment: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.staff.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = StaffAssignment( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + department_id=body.department_id, + doctor_id=body.doctor_id, + code=code, + role_code=body.role_code, + is_primary=body.is_primary, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.staff.add(entity) + self.events.publish( + event_type=HealthcareEventType.STAFF_ASSIGNMENT_CREATED, + aggregate_type="staff_assignment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_policy( + self, tenant_id: UUID, body: ClinicPolicyCreate, *, actor: CurrentUser | None = None + ) -> ClinicPolicy: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + validate_clinic_policy_windows( + booking_lead_time_hours=body.booking_lead_time_hours, + cancellation_window_hours=body.cancellation_window_hours, + ) + code = validate_code(body.code) + if await self.policies.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = ClinicPolicy( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=body.name, + kind=body.kind, + booking_lead_time_hours=body.booking_lead_time_hours, + cancellation_window_hours=body.cancellation_window_hours, + privacy_flags=body.privacy_flags, + rules=body.rules, + status=body.status, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.policies.add(entity) + self.events.publish( + event_type=HealthcareEventType.CLINIC_POLICY_CREATED, + aggregate_type="clinic_policy", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_operating_hours( + self, + tenant_id: UUID, + body: OperatingHoursPolicyCreate, + *, + actor: CurrentUser | None = None, + ) -> OperatingHoursPolicy: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if body.branch_id and await self.branches.get(tenant_id, body.branch_id) is None: + raise NotFoundError("شعبه یافت نشد") + validate_weekly_hours(body.weekly_hours) + code = validate_code(body.code) + if await self.hours.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = OperatingHoursPolicy( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + code=code, + name=body.name, + weekly_hours=body.weekly_hours, + exceptions=body.exceptions, + timezone=body.timezone, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.hours.add(entity) + self.events.publish( + event_type=HealthcareEventType.OPERATING_HOURS_POLICY_CREATED, + aggregate_type="operating_hours_policy", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_services( + self, tenant_id: UUID, *, clinic_id: UUID | None = None, offset: int = 0, limit: int = 20 + ): + if clinic_id: + return list(await self.services.list_by_clinic(tenant_id, clinic_id, offset=offset, limit=limit)) + return list(await self.services.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_staff(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.staff.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_policies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.policies.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_operating_hours(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.hours.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/delivery_integration.py b/backend/services/healthcare/app/services/delivery_integration.py new file mode 100644 index 0000000..d9b8cc4 --- /dev/null +++ b/backend/services/healthcare/app/services/delivery_integration.py @@ -0,0 +1,199 @@ +"""Delivery integration application services — Phase 13.7.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.delivery_integration import ( + DeliveryIntegrationRegistration, + DeliveryJobIntent, + DeliveryJobStatusSnapshot, +) +from app.models.types import DeliveryJobStatus, PrescriptionOrderStatus +from app.providers.mocks import MockCommunicationProvider, MockDeliveryProvider +from app.repositories.delivery_integration import ( + DeliveryIntegrationRepository, + DeliveryJobIntentRepository, + DeliveryJobStatusSnapshotRepository, +) +from app.repositories.pharmacy import PharmacyRepository, PrescriptionOrderRepository +from app.schemas.delivery_integration import ( + DeliveryIntegrationCreate, + DeliveryJobIntentCreate, + DeliveryStatusWebhook, +) +from app.services.foundation import _actor_id +from app.validators import validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class DeliveryIntegrationService: + def __init__( + self, + session: AsyncSession, + *, + delivery: MockDeliveryProvider | None = None, + communication: MockCommunicationProvider | None = None, + ) -> None: + self.session = session + self.registrations = DeliveryIntegrationRepository(session) + self.intents = DeliveryJobIntentRepository(session) + self.snapshots = DeliveryJobStatusSnapshotRepository(session) + self.orders = PrescriptionOrderRepository(session) + self.pharmacies = PharmacyRepository(session) + self.delivery = delivery or MockDeliveryProvider() + self.communication = communication or MockCommunicationProvider() + self.events = get_event_publisher() + + async def register( + self, tenant_id: UUID, body: DeliveryIntegrationCreate, *, actor: CurrentUser | None = None + ) -> DeliveryIntegrationRegistration: + code = validate_code(body.code) + if await self.registrations.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = DeliveryIntegrationRegistration( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + pharmacy_id=body.pharmacy_id, + code=code, + name=body.name, + delivery_merchant_ref=body.delivery_merchant_ref, + webhook_secret_ref=body.webhook_secret_ref, + status=body.status, + config=body.config, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.registrations.add(entity) + self.events.publish( + event_type=HealthcareEventType.DELIVERY_INTEGRATION_REGISTERED, + aggregate_type="delivery_integration_registration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_intent_for_order( + self, + tenant_id: UUID, + prescription_order_id: UUID, + body: DeliveryJobIntentCreate, + *, + actor: CurrentUser | None = None, + ) -> DeliveryJobIntent: + existing = await self.intents.get_by_idempotency(tenant_id, body.idempotency_key) + if existing: + return existing + order = await self.orders.get(tenant_id, prescription_order_id) + if order is None: + raise NotFoundError("سفارش نسخه یافت نشد") + if order.status != PrescriptionOrderStatus.READY: + raise AppError( + "سفارش باید آماده باشد", + status_code=409, + error_code="prescription_not_ready", + ) + registration = await self.registrations.get(tenant_id, body.registration_id) + if registration is None: + raise NotFoundError("ثبت‌نام Delivery یافت نشد") + code = validate_code(body.code) + if await self.intents.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + delivery_result = await self.delivery.request_delivery( + tenant_id=tenant_id, + request={ + "prescription_order_id": str(prescription_order_id), + "merchant_ref": registration.delivery_merchant_ref, + "pickup_address": body.pickup_address, + "dropoff_address": body.dropoff_address, + }, + ) + entity = DeliveryJobIntent( + tenant_id=tenant_id, + registration_id=body.registration_id, + prescription_order_id=prescription_order_id, + code=code, + delivery_job_ref=delivery_result["delivery_ref"], + status=DeliveryJobStatus.QUEUED, + idempotency_key=body.idempotency_key, + pickup_address=body.pickup_address, + dropoff_address=body.dropoff_address, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.intents.add(entity) + await self.snapshots.add( + DeliveryJobStatusSnapshot( + tenant_id=tenant_id, + delivery_job_intent_id=entity.id, + status=DeliveryJobStatus.QUEUED, + external_status="queued", + recorded_at=datetime.now(timezone.utc), + payload=delivery_result, + ) + ) + self.events.publish( + event_type=HealthcareEventType.DELIVERY_JOB_INTENT_CREATED, + aggregate_type="delivery_job_intent", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "delivery_ref": entity.delivery_job_ref}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def apply_status_webhook( + self, tenant_id: UUID, body: DeliveryStatusWebhook + ) -> DeliveryJobIntent: + intent = await self.intents.get_by_delivery_ref(tenant_id, body.delivery_job_ref) + if intent is None: + raise NotFoundError("Delivery job intent یافت نشد") + intent.status = body.status + intent.version += 1 + await self.snapshots.add( + DeliveryJobStatusSnapshot( + tenant_id=tenant_id, + delivery_job_intent_id=intent.id, + status=body.status, + external_status=body.external_status, + recorded_at=datetime.now(timezone.utc), + payload=body.payload, + ) + ) + self.events.publish( + event_type=HealthcareEventType.DELIVERY_JOB_STATUS_UPDATED, + aggregate_type="delivery_job_intent", + aggregate_id=intent.id, + tenant_id=tenant_id, + payload={"status": body.status.value, "delivery_ref": body.delivery_job_ref}, + ) + if body.status == DeliveryJobStatus.DELIVERED: + order = await self.orders.get(tenant_id, intent.prescription_order_id) + if order: + patient_phone = "unknown" + await self.communication.send( + tenant_id=tenant_id, + channel="sms", + template_key="healthcare.delivery.delivered", + to=patient_phone, + payload={"delivery_ref": body.delivery_job_ref}, + ) + await self.session.commit() + await self.session.refresh(intent) + return intent + + async def list_intents(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.intents.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_registrations(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.registrations.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/doctor_panel.py b/backend/services/healthcare/app/services/doctor_panel.py new file mode 100644 index 0000000..e0203e7 --- /dev/null +++ b/backend/services/healthcare/app/services/doctor_panel.py @@ -0,0 +1,390 @@ +"""Doctor panel application services — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.doctor_panel import VisitNote, VisitSession +from app.models.types import ( + AppointmentLifecycleAction, + AppointmentStatus, + AuditAction, + VisitSessionAction, + VisitSessionStatus, +) +from app.policies.doctor_panel import VisitSessionPolicy +from app.repositories.appointments import AppointmentRepository +from app.repositories.doctor_panel import VisitNoteRepository, VisitSessionRepository +from app.repositories.foundation import ClinicRepository +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.appointments import AppointmentLifecycleRequest +from app.schemas.doctor_panel import ( + DoctorDashboardRead, + DoctorQueueRead, + VisitNoteCreate, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, +) +from app.services.appointments import AppointmentService +from app.services.audit_service import AuditService +from app.services.foundation import _actor_id, _jsonable_changes +from app.specifications.appointments import AppointmentListSpec +from app.specifications.doctor_panel import VisitSessionListSpec +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class VisitSessionService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = VisitSessionRepository(session) + self.appointments = AppointmentRepository(session) + self.appointment_service = AppointmentService(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.patients = PatientRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policy = VisitSessionPolicy() + + async def _validate_refs(self, tenant_id: UUID, body: VisitSessionCreate) -> None: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + appt = await self.appointments.get(tenant_id, body.appointment_id) + if appt is None: + raise NotFoundError("نوبت یافت نشد") + if appt.doctor_id != body.doctor_id or appt.patient_id != body.patient_id: + raise AppError( + "نوبت با پزشک/بیمار مطابقت ندارد", + status_code=422, + error_code="appointment_mismatch", + ) + + async def create( + self, tenant_id: UUID, body: VisitSessionCreate, *, actor: CurrentUser | None = None + ) -> VisitSession: + await self._validate_refs(tenant_id, body) + if await self.repo.get_by_appointment(tenant_id, body.appointment_id): + raise AppError( + "جلسه ویزیت برای این نوبت وجود دارد", + status_code=409, + error_code="visit_session_exists", + ) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = VisitSession( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + appointment_id=body.appointment_id, + code=code, + status=VisitSessionStatus.QUEUED, + queue_position=body.queue_position, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="visit_session", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.VISIT_SESSION_CREATED, + aggregate_type="visit_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "doctor_id": str(entity.doctor_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, visit_session_id: UUID) -> VisitSession: + entity = await self.repo.get(tenant_id, visit_session_id) + if entity is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + return entity + + async def list( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ) -> list[VisitSession]: + return list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec)) + + async def _apply_action( + self, + tenant_id: UUID, + visit_session_id: UUID, + action: VisitSessionAction, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + event_type: HealthcareEventType, + ) -> VisitSession: + entity = await self.get(tenant_id, visit_session_id) + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + now = datetime.now(timezone.utc) + if action == VisitSessionAction.START: + entity.started_at = now + appt = await self.appointments.get(tenant_id, entity.appointment_id) + if appt and appt.status == AppointmentStatus.CONFIRMED: + await self.appointment_service._apply_lifecycle( + tenant_id, + entity.appointment_id, + AppointmentLifecycleAction.CHECK_IN, + AppointmentLifecycleRequest(version=appt.version), + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CHECKED_IN, + ) + elif action == VisitSessionAction.FINISH: + entity.finished_at = now + appt = await self.appointments.get(tenant_id, entity.appointment_id) + if appt and appt.status in ( + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + ): + await self.appointment_service._apply_lifecycle( + tenant_id, + entity.appointment_id, + AppointmentLifecycleAction.COMPLETE, + AppointmentLifecycleRequest(version=appt.version), + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_COMPLETED, + ) + entity.status = new_status + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="visit_session", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"action": action.value, "status": new_status.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="visit_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def start( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.START, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_STARTED, + ) + + async def finish( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.FINISH, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_FINISHED, + ) + + async def defer( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.DEFER, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_DEFERRED, + ) + + async def call_next( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + entity = await self.get(tenant_id, visit_session_id) + ensure_optimistic_version(entity, body.version) + self.policy.ensure_transition(action=VisitSessionAction.CALL_NEXT, current=entity.status) + entity.queue_position = 1 + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.VISIT_SESSION_CALLED, + aggregate_type="visit_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def dashboard(self, tenant_id: UUID, *, doctor_id: UUID) -> DoctorDashboardRead: + if await self.doctors.get(tenant_id, doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + queued = await self.repo.count_by_status( + tenant_id, doctor_id=doctor_id, status=VisitSessionStatus.QUEUED + ) + in_progress = await self.repo.count_by_status( + tenant_id, doctor_id=doctor_id, status=VisitSessionStatus.IN_PROGRESS + ) + completed_today = await self.repo.count_completed_today(tenant_id, doctor_id=doctor_id) + appt_spec = AppointmentListSpec(doctor_id=doctor_id, sort_by="scheduled_start", sort_dir="asc") + upcoming, _ = await self.appointment_service.list( + tenant_id, offset=0, limit=10, spec=appt_spec + ) + return DoctorDashboardRead( + doctor_id=doctor_id, + queued_count=queued, + in_progress_count=in_progress, + completed_today_count=completed_today, + upcoming_appointments=upcoming, + ) + + async def queue(self, tenant_id: UUID, *, doctor_id: UUID) -> DoctorQueueRead: + spec = VisitSessionListSpec( + doctor_id=doctor_id, + status=VisitSessionStatus.QUEUED, + sort_by="queue_position", + sort_dir="asc", + ) + items = await self.list(tenant_id, offset=0, limit=100, spec=spec) + return DoctorQueueRead(doctor_id=doctor_id, items=items) + + +class VisitNoteService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = VisitNoteRepository(session) + self.sessions = VisitSessionRepository(session) + self.doctors = DoctorRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, tenant_id: UUID, body: VisitNoteCreate, *, actor: CurrentUser | None = None + ) -> VisitNote: + if await self.sessions.get(tenant_id, body.visit_session_id) is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = VisitNote( + tenant_id=tenant_id, + visit_session_id=body.visit_session_id, + doctor_id=body.doctor_id, + code=code, + title=body.title, + body=body.body, + storage_file_ref=body.storage_file_ref, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + self.events.publish( + event_type=HealthcareEventType.VISIT_NOTE_CREATED, + aggregate_type="visit_note", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "visit_session_id": str(entity.visit_session_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def update( + self, + tenant_id: UUID, + note_id: UUID, + body: VisitNoteUpdate, + *, + actor: CurrentUser | None = None, + ) -> VisitNote: + entity = await self.repo.get(tenant_id, note_id) + if entity is None: + raise NotFoundError("یادداشت ویزیت یافت نشد") + ensure_optimistic_version(entity, body.version) + if body.title is not None: + entity.title = body.title + if body.body is not None: + entity.body = body.body + if body.storage_file_ref is not None: + entity.storage_file_ref = body.storage_file_ref + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.VISIT_NOTE_UPDATED, + aggregate_type="visit_note", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID) -> list[VisitNote]: + if await self.sessions.get(tenant_id, visit_session_id) is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + return list(await self.repo.list_by_session(tenant_id, visit_session_id)) diff --git a/backend/services/healthcare/app/services/foundation.py b/backend/services/healthcare/app/services/foundation.py new file mode 100644 index 0000000..ea7fb8a --- /dev/null +++ b/backend/services/healthcare/app/services/foundation.py @@ -0,0 +1,786 @@ +"""Healthcare foundation application services — Phase 13.0.""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.foundation import ( + Branch, + Clinic, + Department, + ExternalProviderConfig, + HealthcareConfiguration, + HealthcareSetting, +) +from app.models.types import AuditAction +from app.repositories.foundation import ( + BranchRepository, + ClinicRepository, + DepartmentRepository, + ExternalProviderConfigRepository, + HealthcareConfigurationRepository, + HealthcareSettingRepository, +) +from app.schemas.foundation import ( + BranchCreate, + BranchUpdate, + ClinicCreate, + ClinicUpdate, + DepartmentCreate, + DepartmentUpdate, + ExternalProviderConfigCreate, + ExternalProviderConfigUpdate, + HealthcareConfigurationCreate, + HealthcareConfigurationUpdate, + HealthcareSettingUpsert, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ClinicService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ClinicRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ClinicCreate, + *, + actor: CurrentUser | None = None, + ) -> Clinic: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + currency = validate_currency_code(body.currency_code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد کلینیک تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = Clinic( + tenant_id=tenant_id, + code=code, + name=name, + description=body.description, + status=body.status, + legal_name=body.legal_name, + timezone=body.timezone, + language=body.language, + currency_code=currency, + settings=body.settings, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد کلینیک تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="clinic", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.CLINIC_CREATED, + aggregate_type="clinic", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "name": entity.name}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Clinic: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("کلینیک یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[Clinic]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ClinicUpdate, + *, + actor: CurrentUser | None = None, + ) -> Clinic: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="clinic", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.CLINIC_UPDATED, + aggregate_type="clinic", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Clinic: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="clinic", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class BranchService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = BranchRepository(session) + self.clinics = ClinicRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: BranchCreate, + *, + actor: CurrentUser | None = None, + ) -> Branch: + clinic = await self.clinics.get(tenant_id, body.clinic_id) + if clinic is None: + raise NotFoundError("کلینیک یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + entity = Branch( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=name, + description=body.description, + status=body.status, + address=body.address, + timezone=body.timezone, + working_hours=body.working_hours, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد شعبه تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="branch", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.BRANCH_CREATED, + aggregate_type="branch", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "clinic_id": str(entity.clinic_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Branch: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("شعبه یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[Branch]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: BranchUpdate, + *, + actor: CurrentUser | None = None, + ) -> Branch: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="branch", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.BRANCH_UPDATED, + aggregate_type="branch", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Branch: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="branch", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class DepartmentService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DepartmentRepository(session) + self.clinics = ClinicRepository(session) + self.branches = BranchRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: DepartmentCreate, + *, + actor: CurrentUser | None = None, + ) -> Department: + clinic = await self.clinics.get(tenant_id, body.clinic_id) + if clinic is None: + raise NotFoundError("کلینیک یافت نشد") + if body.branch_id is not None: + branch = await self.branches.get(tenant_id, body.branch_id) + if branch is None: + raise NotFoundError("شعبه یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + entity = Department( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + code=code, + name=name, + description=body.description, + status=body.status, + specialty=body.specialty, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد بخش تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="department", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.DEPARTMENT_CREATED, + aggregate_type="department", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "clinic_id": str(entity.clinic_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Department: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("بخش یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[Department]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DepartmentUpdate, + *, + actor: CurrentUser | None = None, + ) -> Department: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "branch_id" in data and data["branch_id"] is not None: + branch = await self.branches.get(tenant_id, data["branch_id"]) + if branch is None: + raise NotFoundError("شعبه یافت نشد") + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="department", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.DEPARTMENT_UPDATED, + aggregate_type="department", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Department: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="department", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExternalProviderConfigService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExternalProviderConfigRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExternalProviderConfigCreate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + adapter_key = validate_non_empty(body.adapter_key, "adapter_key") + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد ارائه‌دهنده تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExternalProviderConfig( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=name, + provider_kind=body.provider_kind, + adapter_key=adapter_key, + status=body.status, + priority=body.priority, + credentials_ref=body.credentials_ref, + config=body.config, + capabilities=body.capabilities, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.REGISTER, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.EXTERNAL_PROVIDER_REGISTERED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "adapter_key": entity.adapter_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ارائه‌دهنده یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExternalProviderConfig]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExternalProviderConfigUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "adapter_key" in data and data["adapter_key"] is not None: + data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.EXTERNAL_PROVIDER_UPDATED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider_config", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class HealthcareConfigurationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = HealthcareConfigurationRepository(session) + self.clinics = ClinicRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: HealthcareConfigurationCreate, + *, + actor: CurrentUser | None = None, + ) -> HealthcareConfiguration: + clinic = await self.clinics.get(tenant_id, body.clinic_id) + if clinic is None: + raise NotFoundError("کلینیک یافت نشد") + code = validate_code(body.code) + currency = validate_currency_code(body.currency_code) + entity = HealthcareConfiguration( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + code=code, + working_hours=body.working_hours, + clinical_policies=body.clinical_policies, + privacy_policies=body.privacy_policies, + custom_fields=body.custom_fields, + timezone=body.timezone, + language=body.language, + currency_code=currency, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد پیکربندی تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="healthcare_configuration", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.CONFIGURATION_CREATED, + aggregate_type="healthcare_configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> HealthcareConfiguration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پیکربندی یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[HealthcareConfiguration]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: HealthcareConfigurationUpdate, + *, + actor: CurrentUser | None = None, + ) -> HealthcareConfiguration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="healthcare_configuration", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.CONFIGURATION_UPDATED, + aggregate_type="healthcare_configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> HealthcareConfiguration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="healthcare_configuration", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class HealthcareSettingService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = HealthcareSettingRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def upsert( + self, + tenant_id: UUID, + body: HealthcareSettingUpsert, + *, + actor: CurrentUser | None = None, + ) -> HealthcareSetting: + key = validate_non_empty(body.key, "key") + existing = await self.repo.get_by_key( + tenant_id, + key, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + ) + if existing is None: + entity = HealthcareSetting( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + key=key, + value=body.value, + description=body.description, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + else: + entity = existing + entity.value = body.value + entity.description = body.description + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="healthcare_setting", + entity_id=entity.id, + action=AuditAction.UPDATE if existing else AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.SETTING_UPDATED, + aggregate_type="healthcare_setting", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"key": entity.key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[HealthcareSetting]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/medical_records.py b/backend/services/healthcare/app/services/medical_records.py new file mode 100644 index 0000000..f6fd13f --- /dev/null +++ b/backend/services/healthcare/app/services/medical_records.py @@ -0,0 +1,334 @@ +"""Medical record application services — Phase 13.5.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.medical_records import ( + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.models.types import EncounterStatus +from app.policies.medical_records import MedicalRecordAccessPolicy +from app.repositories.foundation import ClinicRepository +from app.repositories.medical_records import ( + AllergyRepository, + ConditionRepository, + EncounterRepository, + ImmunizationRecordRepository, + MedicalRecordAccessLogRepository, + MedicalRecordRepository, + MedicationStatementRepository, +) +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.medical_records import ( + AllergyCreate, + ConditionCreate, + EncounterCreate, + EncounterFinalize, + EncounterUpdate, + ImmunizationCreate, + MedicalRecordAccessRequest, + MedicalRecordCreate, + MedicationCreate, +) +from app.services.foundation import _actor_id +from app.validators import ensure_optimistic_version, validate_code +from app.validators.medical_records import ensure_encounter_can_finalize, ensure_encounter_mutable +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class MedicalRecordService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.records = MedicalRecordRepository(session) + self.encounters = EncounterRepository(session) + self.conditions = ConditionRepository(session) + self.allergies = AllergyRepository(session) + self.medications = MedicationStatementRepository(session) + self.immunizations = ImmunizationRecordRepository(session) + self.access_logs = MedicalRecordAccessLogRepository(session) + self.clinics = ClinicRepository(session) + self.patients = PatientRepository(session) + self.doctors = DoctorRepository(session) + self.access_policy = MedicalRecordAccessPolicy() + self.events = get_event_publisher() + + async def _log_access( + self, + tenant_id: UUID, + medical_record_id: UUID, + *, + actor_user_id: str | None, + action: str, + reason: str | None = None, + ) -> None: + log = MedicalRecordAccessLog( + tenant_id=tenant_id, + medical_record_id=medical_record_id, + actor_user_id=actor_user_id or "unknown", + action=action, + reason=reason, + ) + await self.access_logs.add(log) + self.events.publish( + event_type=HealthcareEventType.MEDICAL_RECORD_ACCESSED, + aggregate_type="medical_record", + aggregate_id=medical_record_id, + tenant_id=tenant_id, + payload={"action": action}, + ) + + async def create_record( + self, tenant_id: UUID, body: MedicalRecordCreate, *, actor: CurrentUser | None = None + ) -> MedicalRecord: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + code = validate_code(body.code) + if await self.records.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = MedicalRecord( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + patient_id=body.patient_id, + code=code, + access_policy=body.access_policy, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.records.add(entity) + self.events.publish( + event_type=HealthcareEventType.MEDICAL_RECORD_CREATED, + aggregate_type="medical_record", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_record( + self, + tenant_id: UUID, + record_id: UUID, + *, + actor: CurrentUser | None = None, + access: MedicalRecordAccessRequest | None = None, + ) -> MedicalRecord: + entity = await self.records.get(tenant_id, record_id) + if entity is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + self.access_policy.ensure_read_access( + access_policy=entity.access_policy, + actor_user_id=_actor_id(actor) or "unknown", + ) + await self._log_access( + tenant_id, + entity.id, + actor_user_id=_actor_id(actor), + action="read", + reason=access.reason if access else None, + ) + await self.session.commit() + return entity + + async def create_encounter( + self, tenant_id: UUID, body: EncounterCreate, *, actor: CurrentUser | None = None + ) -> Encounter: + if await self.records.get(tenant_id, body.medical_record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.encounters.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Encounter( + tenant_id=tenant_id, + medical_record_id=body.medical_record_id, + visit_session_id=body.visit_session_id, + appointment_id=body.appointment_id, + doctor_id=body.doctor_id, + code=code, + status=EncounterStatus.DRAFT, + chief_complaint=body.chief_complaint, + clinical_summary=body.clinical_summary, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.encounters.add(entity) + self.events.publish( + event_type=HealthcareEventType.ENCOUNTER_CREATED, + aggregate_type="encounter", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def update_encounter( + self, + tenant_id: UUID, + encounter_id: UUID, + body: EncounterUpdate, + *, + actor: CurrentUser | None = None, + ) -> Encounter: + entity = await self.encounters.get(tenant_id, encounter_id) + if entity is None: + raise NotFoundError("Encounter یافت نشد") + ensure_encounter_mutable(entity.status) + ensure_optimistic_version(entity, body.version) + if body.chief_complaint is not None: + entity.chief_complaint = body.chief_complaint + if body.clinical_summary is not None: + entity.clinical_summary = body.clinical_summary + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + if entity.status == EncounterStatus.DRAFT: + entity.status = EncounterStatus.IN_PROGRESS + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def finalize_encounter( + self, + tenant_id: UUID, + encounter_id: UUID, + body: EncounterFinalize, + *, + actor: CurrentUser | None = None, + ) -> Encounter: + entity = await self.encounters.get(tenant_id, encounter_id) + if entity is None: + raise NotFoundError("Encounter یافت نشد") + ensure_encounter_can_finalize(entity.status) + ensure_optimistic_version(entity, body.version) + entity.status = EncounterStatus.FINALIZED + entity.finalized_at = datetime.now(timezone.utc) + entity.addendum = body.addendum + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.ENCOUNTER_FINALIZED, + aggregate_type="encounter", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_condition( + self, tenant_id: UUID, record_id: UUID, body: ConditionCreate, *, actor=None + ) -> Condition: + record = await self.records.get(tenant_id, record_id) + if record is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + code = validate_code(body.code) + entity = Condition( + tenant_id=tenant_id, + medical_record_id=record_id, + code=code, + display=body.display, + clinical_status=body.clinical_status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.conditions.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_allergy( + self, tenant_id: UUID, record_id: UUID, body: AllergyCreate, *, actor=None + ) -> Allergy: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = Allergy( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + substance=body.substance, + severity=body.severity, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.allergies.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_medication( + self, tenant_id: UUID, record_id: UUID, body: MedicationCreate, *, actor=None + ) -> MedicationStatement: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = MedicationStatement( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + medication_code=body.medication_code, + display=body.display, + dosage=body.dosage, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.medications.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_immunization( + self, tenant_id: UUID, record_id: UUID, body: ImmunizationCreate, *, actor=None + ) -> ImmunizationRecord: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = ImmunizationRecord( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + vaccine_code=body.vaccine_code, + administered_on=body.administered_on, + storage_file_ref=body.storage_file_ref, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.immunizations.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_clinical_lists(self, tenant_id: UUID, record_id: UUID): + return { + "conditions": list(await self.conditions.list_by_record(tenant_id, record_id)), + "allergies": list(await self.allergies.list_by_record(tenant_id, record_id)), + "medications": list(await self.medications.list_by_record(tenant_id, record_id)), + "immunizations": list(await self.immunizations.list_by_record(tenant_id, record_id)), + } + + async def list_access_logs(self, tenant_id: UUID, record_id: UUID): + return list(await self.access_logs.list_by_record(tenant_id, record_id)) diff --git a/backend/services/healthcare/app/services/patient_portal.py b/backend/services/healthcare/app/services/patient_portal.py new file mode 100644 index 0000000..9c53b83 --- /dev/null +++ b/backend/services/healthcare/app/services/patient_portal.py @@ -0,0 +1,223 @@ +"""Patient portal application services — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.patient_portal import ( + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.repositories.foundation import ClinicRepository +from app.repositories.patient_portal import ( + PatientDocumentRefRepository, + PatientNotificationPreferenceRepository, + PatientPortalProfileRepository, +) +from app.repositories.profiles import PatientRepository +from app.schemas.appointments import AppointmentCreate, AppointmentLifecycleRequest +from app.schemas.patient_portal import ( + PatientDocumentRefCreate, + PatientNotificationPreferenceUpsert, + PatientPortalProfileCreate, + PatientPortalProfileUpdate, +) +from app.services.appointments import AppointmentService +from app.services.foundation import _actor_id +from app.specifications.appointments import AppointmentListSpec +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class PatientPortalService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.profiles = PatientPortalProfileRepository(session) + self.documents = PatientDocumentRefRepository(session) + self.prefs = PatientNotificationPreferenceRepository(session) + self.patients = PatientRepository(session) + self.clinics = ClinicRepository(session) + self.appointments = AppointmentService(session) + self.events = get_event_publisher() + + async def get_or_create_profile( + self, tenant_id: UUID, patient_id: UUID, *, actor: CurrentUser | None = None + ) -> PatientPortalProfile: + existing = await self.profiles.get_by_patient(tenant_id, patient_id) + if existing: + return existing + body = PatientPortalProfileCreate(patient_id=patient_id) + return await self.create_profile(tenant_id, body, actor=actor) + + async def create_profile( + self, tenant_id: UUID, body: PatientPortalProfileCreate, *, actor: CurrentUser | None = None + ) -> PatientPortalProfile: + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if await self.profiles.get_by_patient(tenant_id, body.patient_id): + raise AppError("پروفایل پرتال وجود دارد", status_code=409, error_code="profile_exists") + if body.preferred_clinic_id and await self.clinics.get(tenant_id, body.preferred_clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + entity = PatientPortalProfile( + tenant_id=tenant_id, + patient_id=body.patient_id, + preferred_clinic_id=body.preferred_clinic_id, + preferred_language=body.preferred_language, + emergency_contact_ref=body.emergency_contact_ref, + notification_prefs=body.notification_prefs, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.profiles.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_PORTAL_PROFILE_CREATED, + aggregate_type="patient_portal_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"patient_id": str(entity.patient_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def update_profile( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientPortalProfileUpdate, + *, + actor: CurrentUser | None = None, + ) -> PatientPortalProfile: + entity = await self.profiles.get_by_patient(tenant_id, patient_id) + if entity is None: + raise NotFoundError("پروفایل پرتال یافت نشد") + ensure_optimistic_version(entity, body.version) + if body.preferred_clinic_id is not None: + if body.preferred_clinic_id and await self.clinics.get(tenant_id, body.preferred_clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + entity.preferred_clinic_id = body.preferred_clinic_id + if body.preferred_language is not None: + entity.preferred_language = body.preferred_language + if body.emergency_contact_ref is not None: + entity.emergency_contact_ref = body.emergency_contact_ref + if body.notification_prefs is not None: + entity.notification_prefs = body.notification_prefs + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.PATIENT_PORTAL_PROFILE_UPDATED, + aggregate_type="patient_portal_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"patient_id": str(entity.patient_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_appointments(self, tenant_id: UUID, patient_id: UUID): + spec = AppointmentListSpec(patient_id=patient_id, sort_by="scheduled_start", sort_dir="desc") + return await self.appointments.list(tenant_id, offset=0, limit=50, spec=spec) + + async def book_appointment( + self, tenant_id: UUID, patient_id: UUID, body: AppointmentCreate, *, actor=None + ): + if body.patient_id != patient_id: + raise AppError("دسترسی به بیمار دیگر مجاز نیست", status_code=403, error_code="patient_mismatch") + return await self.appointments.book(tenant_id, body, actor=actor) + + async def cancel_appointment( + self, + tenant_id: UUID, + patient_id: UUID, + appointment_id: UUID, + body: AppointmentLifecycleRequest, + *, + actor=None, + ): + appt = await self.appointments.get(tenant_id, appointment_id) + if appt.patient_id != patient_id: + raise AppError("دسترسی به نوبت دیگر مجاز نیست", status_code=403, error_code="patient_mismatch") + return await self.appointments.cancel(tenant_id, appointment_id, body, actor=actor) + + async def create_document( + self, tenant_id: UUID, patient_id: UUID, body: PatientDocumentRefCreate, *, actor=None + ) -> PatientDocumentRef: + code = validate_code(body.code) + if await self.documents.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = PatientDocumentRef( + tenant_id=tenant_id, + patient_id=patient_id, + code=code, + title=body.title, + storage_file_ref=body.storage_file_ref, + document_type=body.document_type, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.documents.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_DOCUMENT_REF_CREATED, + aggregate_type="patient_document_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_documents(self, tenant_id: UUID, patient_id: UUID): + return list(await self.documents.list_by_patient(tenant_id, patient_id)) + + async def upsert_notification_pref( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientNotificationPreferenceUpsert, + *, + actor=None, + ) -> PatientNotificationPreference: + existing = await self.prefs.get_pref( + tenant_id, patient_id, body.channel, body.template_key + ) + if existing: + existing.enabled = body.enabled + existing.metadata_json = body.metadata_json + existing.updated_by = _actor_id(actor) + entity = existing + else: + entity = PatientNotificationPreference( + tenant_id=tenant_id, + patient_id=patient_id, + channel=body.channel, + template_key=body.template_key, + enabled=body.enabled, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.prefs.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_NOTIFICATION_PREF_UPDATED, + aggregate_type="patient_notification_preference", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"channel": entity.channel, "template_key": entity.template_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_notification_prefs(self, tenant_id: UUID, patient_id: UUID): + return list(await self.prefs.list_by_patient(tenant_id, patient_id)) diff --git a/backend/services/healthcare/app/services/pharmacy.py b/backend/services/healthcare/app/services/pharmacy.py new file mode 100644 index 0000000..dad8f6a --- /dev/null +++ b/backend/services/healthcare/app/services/pharmacy.py @@ -0,0 +1,215 @@ +"""Pharmacy application services — Phase 13.6.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.pharmacy import Pharmacy, PrescriptionLine, PrescriptionOrder, PrescriptionStatusHistory +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from app.policies.pharmacy import PrescriptionLifecyclePolicy +from app.providers.mocks import MockCommunicationProvider +from app.repositories.pharmacy import ( + PharmacyRepository, + PrescriptionLineRepository, + PrescriptionOrderRepository, + PrescriptionStatusHistoryRepository, +) +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.pharmacy import PharmacyCreate, PrescriptionLifecycleRequest, PrescriptionOrderCreate +from app.services.foundation import _actor_id +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class PharmacyService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.pharmacies = PharmacyRepository(session) + self.orders = PrescriptionOrderRepository(session) + self.lines = PrescriptionLineRepository(session) + self.history = PrescriptionStatusHistoryRepository(session) + self.patients = PatientRepository(session) + self.doctors = DoctorRepository(session) + self.policy = PrescriptionLifecyclePolicy() + self.communication = MockCommunicationProvider() + self.events = get_event_publisher() + + async def create_pharmacy( + self, tenant_id: UUID, body: PharmacyCreate, *, actor: CurrentUser | None = None + ) -> Pharmacy: + code = validate_code(body.code) + if await self.pharmacies.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Pharmacy( + tenant_id=tenant_id, + code=code, + name=body.name, + contact=body.contact, + hours=body.hours, + service_area_ref=body.service_area_ref, + status=body.status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.pharmacies.add(entity) + self.events.publish( + event_type=HealthcareEventType.PHARMACY_CREATED, + aggregate_type="pharmacy", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def submit_order( + self, tenant_id: UUID, body: PrescriptionOrderCreate, *, actor: CurrentUser | None = None + ) -> PrescriptionOrder: + if not body.lines: + raise AppError("حداقل یک خط نسخه الزامی است", status_code=422, error_code="empty_prescription") + if await self.pharmacies.get(tenant_id, body.pharmacy_id) is None: + raise NotFoundError("داروخانه یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.orders.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = PrescriptionOrder( + tenant_id=tenant_id, + pharmacy_id=body.pharmacy_id, + encounter_id=body.encounter_id, + visit_session_id=body.visit_session_id, + patient_id=body.patient_id, + doctor_id=body.doctor_id, + code=code, + status=PrescriptionOrderStatus.SUBMITTED, + status_changed_at=datetime.now(timezone.utc), + notes=body.notes, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.orders.add(entity) + for line in body.lines: + await self.lines.add( + PrescriptionLine( + tenant_id=tenant_id, + prescription_order_id=entity.id, + line_no=line.line_no, + medication_code=line.medication_code, + display=line.display, + quantity=line.quantity, + dosage_instructions=line.dosage_instructions, + metadata_json=line.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + ) + self.events.publish( + event_type=HealthcareEventType.PRESCRIPTION_ORDER_SUBMITTED, + aggregate_type="prescription_order", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _apply_lifecycle( + self, + tenant_id: UUID, + order_id: UUID, + action: PrescriptionLifecycleAction, + body: PrescriptionLifecycleRequest, + *, + actor: CurrentUser | None = None, + ) -> PrescriptionOrder: + entity = await self.orders.get(tenant_id, order_id) + if entity is None: + raise NotFoundError("سفارش نسخه یافت نشد") + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + old_status = entity.status + entity.status = new_status + entity.status_changed_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.history.add( + PrescriptionStatusHistory( + tenant_id=tenant_id, + prescription_order_id=entity.id, + from_status=old_status, + to_status=new_status, + actor_user_id=_actor_id(actor), + reason=body.reason, + ) + ) + self.events.publish( + event_type=HealthcareEventType.PRESCRIPTION_ORDER_STATUS_CHANGED, + aggregate_type="prescription_order", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + if new_status == PrescriptionOrderStatus.READY: + patient = await self.patients.get(tenant_id, entity.patient_id) + to = (patient.contact or {}).get("phone", "unknown") if patient else "unknown" + await self.communication.send( + tenant_id=tenant_id, + channel="sms", + template_key="healthcare.prescription.ready", + to=to, + payload={"order_id": str(entity.id), "code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def accept(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.ACCEPT, body, actor=actor + ) + + async def prepare(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.PREPARE, body, actor=actor + ) + + async def mark_ready(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.MARK_READY, body, actor=actor + ) + + async def complete_pickup( + self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None + ): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.COMPLETE_PICKUP, body, actor=actor + ) + + async def cancel(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.CANCEL, body, actor=actor + ) + + async def get_order(self, tenant_id: UUID, order_id: UUID) -> PrescriptionOrder: + entity = await self.orders.get(tenant_id, order_id) + if entity is None: + raise NotFoundError("سفارش نسخه یافت نشد") + return entity + + async def list_pharmacies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.pharmacies.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_orders(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.orders.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/profiles.py b/backend/services/healthcare/app/services/profiles.py new file mode 100644 index 0000000..db27ed4 --- /dev/null +++ b/backend/services/healthcare/app/services/profiles.py @@ -0,0 +1,320 @@ +"""Doctor and Patient application services — Phase 13.0.""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.models.profiles import Doctor, Patient +from app.models.types import AuditAction +from app.repositories.foundation import ClinicRepository, DepartmentRepository +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.profiles import ( + DoctorCreate, + DoctorUpdate, + PatientCreate, + PatientUpdate, +) +from app.services.audit_service import AuditService +from app.services.foundation import _actor_id, _apply_update, _jsonable_changes +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class DoctorService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = DoctorRepository(session) + self.clinics = ClinicRepository(session) + self.departments = DepartmentRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: DoctorCreate, + *, + actor: CurrentUser | None = None, + ) -> Doctor: + user_id = validate_non_empty(body.user_id, "user_id") + code = validate_code(body.code) + display_name = validate_non_empty(body.display_name, "display_name") + if body.clinic_id is not None: + clinic = await self.clinics.get(tenant_id, body.clinic_id) + if clinic is None: + raise NotFoundError("کلینیک یافت نشد") + if body.department_id is not None: + department = await self.departments.get(tenant_id, body.department_id) + if department is None: + raise NotFoundError("بخش یافت نشد") + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد پزشک تکراری است", + error_code="duplicate_code", + status_code=409, + ) + if await self.repo.get_by_user_id(tenant_id, user_id): + raise AppError( + "پروفایل پزشک برای این کاربر وجود دارد", + error_code="duplicate_user", + status_code=409, + ) + entity = Doctor( + tenant_id=tenant_id, + user_id=user_id, + clinic_id=body.clinic_id, + department_id=body.department_id, + code=code, + display_name=display_name, + specialty=body.specialty, + license_number=body.license_number, + status=body.status, + contact=body.contact, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "پروفایل پزشک تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="doctor", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.DOCTOR_CREATED, + aggregate_type="doctor", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "user_id": entity.user_id}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Doctor: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پزشک یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[Doctor]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DoctorUpdate, + *, + actor: CurrentUser | None = None, + ) -> Doctor: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_non_empty(data["display_name"], "display_name") + if "clinic_id" in data and data["clinic_id"] is not None: + clinic = await self.clinics.get(tenant_id, data["clinic_id"]) + if clinic is None: + raise NotFoundError("کلینیک یافت نشد") + if "department_id" in data and data["department_id"] is not None: + department = await self.departments.get(tenant_id, data["department_id"]) + if department is None: + raise NotFoundError("بخش یافت نشد") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="doctor", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.DOCTOR_UPDATED, + aggregate_type="doctor", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Doctor: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="doctor", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class PatientService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = PatientRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: PatientCreate, + *, + actor: CurrentUser | None = None, + ) -> Patient: + user_id = validate_non_empty(body.user_id, "user_id") + code = validate_code(body.code) + display_name = validate_non_empty(body.display_name, "display_name") + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد بیمار تکراری است", + error_code="duplicate_code", + status_code=409, + ) + if await self.repo.get_by_user_id(tenant_id, user_id): + raise AppError( + "پروفایل بیمار برای این کاربر وجود دارد", + error_code="duplicate_user", + status_code=409, + ) + entity = Patient( + tenant_id=tenant_id, + user_id=user_id, + code=code, + display_name=display_name, + status=body.status, + date_of_birth=body.date_of_birth, + gender=body.gender, + contact=body.contact, + emergency_contact=body.emergency_contact, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "پروفایل بیمار تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="patient", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=HealthcareEventType.PATIENT_CREATED, + aggregate_type="patient", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "user_id": entity.user_id}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Patient: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("بیمار یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[Patient]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: PatientUpdate, + *, + actor: CurrentUser | None = None, + ) -> Patient: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_non_empty(data["display_name"], "display_name") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="patient", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=HealthcareEventType.PATIENT_UPDATED, + aggregate_type="patient", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Patient: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="patient", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/healthcare/app/specifications/__init__.py b/backend/services/healthcare/app/specifications/__init__.py new file mode 100644 index 0000000..758ecea --- /dev/null +++ b/backend/services/healthcare/app/specifications/__init__.py @@ -0,0 +1,4 @@ +"""Healthcare query specifications package.""" +from app.specifications.appointments import ALLOWED_SORT_FIELDS, AppointmentListSpec + +__all__ = ["ALLOWED_SORT_FIELDS", "AppointmentListSpec"] diff --git a/backend/services/healthcare/app/specifications/appointments.py b/backend/services/healthcare/app/specifications/appointments.py new file mode 100644 index 0000000..fa11b8d --- /dev/null +++ b/backend/services/healthcare/app/specifications/appointments.py @@ -0,0 +1,25 @@ +"""Appointment list specifications — Phase 13.1.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from app.models.types import AppointmentStatus + +ALLOWED_SORT_FIELDS = frozenset( + {"created_at", "scheduled_start", "status", "code", "updated_at"} +) + + +@dataclass +class AppointmentListSpec: + status: AppointmentStatus | None = None + doctor_id: UUID | None = None + clinic_id: UUID | None = None + patient_id: UUID | None = None + date_from: datetime | None = None + date_to: datetime | None = None + q: str | None = None + sort_by: str = "scheduled_start" + sort_dir: str = "desc" diff --git a/backend/services/healthcare/app/specifications/doctor_panel.py b/backend/services/healthcare/app/specifications/doctor_panel.py new file mode 100644 index 0000000..5f8100f --- /dev/null +++ b/backend/services/healthcare/app/specifications/doctor_panel.py @@ -0,0 +1,18 @@ +"""Doctor panel list specifications — Phase 13.2.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.models.types import VisitSessionStatus + +ALLOWED_SORT_FIELDS = frozenset({"queue_position", "created_at", "started_at"}) + + +@dataclass +class VisitSessionListSpec: + doctor_id: UUID | None = None + clinic_id: UUID | None = None + status: VisitSessionStatus | None = None + sort_by: str = "queue_position" + sort_dir: str = "asc" diff --git a/backend/services/healthcare/app/tests/conftest.py b/backend/services/healthcare/app/tests/conftest.py new file mode 100644 index 0000000..8337ded --- /dev/null +++ b/backend/services/healthcare/app/tests/conftest.py @@ -0,0 +1,66 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +os.environ["ENVIRONMENT"] = "test" +os.environ["AUTH_REQUIRED"] = "false" +os.environ["HEALTHCARE_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["HEALTHCARE_DATABASE_URL_SYNC"] = "sqlite:///:memory:" +os.environ["JWT_VERIFY_SIGNATURE"] = "false" + +from app.core.config import get_settings # noqa: E402 + +get_settings.cache_clear() + +from app.core.database import Base, engine # noqa: E402 +import app.models # noqa: E402, F401 +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + from app.providers.mocks import MockCommunicationProvider, MockDeliveryProvider + + reset_event_publisher() + MockCommunicationProvider.reset() + MockDeliveryProvider.reset() + yield + + +@pytest_asyncio.fixture +async def client(db_setup): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + yield ac + + +def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]: + return {"X-Tenant-ID": str(tenant_id)} diff --git a/backend/services/healthcare/app/tests/test_api.py b/backend/services/healthcare/app/tests/test_api.py new file mode 100644 index 0000000..b561b52 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_api.py @@ -0,0 +1,165 @@ +"""API, health, tenant isolation, and foundation flow tests.""" +from __future__ import annotations + +from app.events.publisher import get_event_publisher +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def test_health_capabilities_metrics(client): + health = await client.get("/health") + assert health.status_code == 200 + body = health.json() + assert body["status"] == "ok" + assert body["service"] == "healthcare-service" + + caps = await client.get("/capabilities") + assert caps.status_code == 200 + c = caps.json() + assert c["phase"] == "13.7" + assert c["commercial_product"] == "Torbat Healthcare" + assert c["features"]["foundation"] is True + assert c["features"]["doctors"] is True + assert c["features"]["appointments"] is True + assert c["features"]["visits"] is True + assert c["features"]["patient_portal"] is True + assert c["features"]["medical_records"] is True + assert c["features"]["pharmacy"] is True + assert c["features"]["delivery_integration"] is True + assert c["independence"]["integration_mode"] == "api_and_events_only" + + metrics = await client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.json()["phase"] == "13.7" + + +async def test_foundation_flow_and_events(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN1", "name": "City Clinic", "status": "active"}, + ) + assert clinic.status_code == 201, clinic.text + clinic_id = clinic.json()["id"] + + branch = await client.post( + "/api/v1/branches", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "BR1", + "name": "Main Branch", + }, + ) + assert branch.status_code == 201, branch.text + + department = await client.post( + "/api/v1/departments", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "CARDIO", + "name": "Cardiology", + }, + ) + assert department.status_code == 201, department.text + + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "user-doctor-1", + "clinic_id": clinic_id, + "code": "DOC1", + "display_name": "Dr. Smith", + }, + ) + assert doctor.status_code == 201, doctor.text + + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "user-patient-1", + "code": "PAT1", + "display_name": "Jane Doe", + }, + ) + assert patient.status_code == 201, patient.text + + provider = await client.post( + "/api/v1/external-providers", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "COMM_X", + "name": "External Comm X", + "provider_kind": "communication", + "adapter_key": "mock_comm", + }, + ) + assert provider.status_code == 201, provider.text + + cfg = await client.post( + "/api/v1/configurations", + headers=headers, + json={"clinic_id": clinic_id, "code": "DEFAULT"}, + ) + assert cfg.status_code == 201, cfg.text + + setting = await client.put( + "/api/v1/settings", + headers=headers, + json={ + "clinic_id": clinic_id, + "key": "default_locale", + "value": {"code": "fa"}, + }, + ) + assert setting.status_code == 200, setting.text + + audit = await client.get( + "/api/v1/audit", + headers=headers, + params={"entity_type": "clinic", "entity_id": clinic_id}, + ) + assert audit.status_code == 200 + assert len(audit.json()) >= 1 + + published = [e.event_type for e in get_event_publisher().published] + assert "healthcare.clinic.created" in published + assert "healthcare.branch.created" in published + assert "healthcare.department.created" in published + assert "healthcare.doctor.created" in published + assert "healthcare.patient.created" in published + assert "healthcare.external_provider.registered" in published + assert "healthcare.setting.updated" in published + + +async def test_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + created = await client.post( + "/api/v1/clinics", + headers=headers_a, + json={"code": "ISO1", "name": "Tenant A Clinic"}, + ) + assert created.status_code == 201 + clinic_id = created.json()["id"] + + denied = await client.get(f"/api/v1/clinics/{clinic_id}", headers=headers_b) + assert denied.status_code == 404 + + listed_b = await client.get("/api/v1/clinics", headers=headers_b) + assert listed_b.status_code == 200 + assert listed_b.json() == [] + + listed_a = await client.get("/api/v1/clinics", headers=headers_a) + assert listed_a.status_code == 200 + assert len(listed_a.json()) == 1 + + +async def test_missing_tenant_header_rejected(client): + res = await client.get("/api/v1/clinics") + assert res.status_code in (400, 422) diff --git a/backend/services/healthcare/app/tests/test_architecture.py b/backend/services/healthcare/app/tests/test_architecture.py new file mode 100644 index 0000000..d09f1d5 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_architecture.py @@ -0,0 +1,177 @@ +"""Architecture tests — Healthcare module boundary enforcement.""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from app.models import foundation as models + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.delivery", + "backend.services.sports_center", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.Clinic, + models.Branch, + models.Department, + models.HealthcareRole, + models.HealthcarePermission, + models.ExternalProviderConfig, + models.HealthcareConfiguration, + models.HealthcareSetting, + models.HealthcareAuditLog, +] + + +def test_all_models_have_tenant_id(): + from app.core.database import Base + import app.models # noqa: F401 + + skip = {"alembic_version"} + for table in Base.metadata.tables.values(): + if table.name in skip: + continue + assert "tenant_id" in table.columns, f"{table.name} missing tenant_id" + + +def test_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 9 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_no_clinical_engine_tables(): + from app.core.database import Base + import app.models # noqa: F401 + + names = set(Base.metadata.tables.keys()) + assert "doctors" in names + assert "patients" in names + assert "departments" in names + assert "outbox_events" in names + assert "appointments" in names + assert "schedules" in names + assert "visit_sessions" in names + assert "medical_records" in names + assert "prescription_orders" in names + assert "delivery_job_intents" in names + forbidden = { + "routing_engine_registrations", + "drivers", + } + assert forbidden.isdisjoint(names) + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "healthcare.view" in ALL_PERMISSIONS + assert "healthcare.clinics.create" in ALL_PERMISSIONS + assert "healthcare.external_providers.manage" in ALL_PERMISSIONS + assert "healthcare.doctors.view" in ALL_PERMISSIONS + assert "healthcare.patients.manage" in ALL_PERMISSIONS + assert "healthcare.audit.view" in ALL_PERMISSIONS + for prefix in PERMISSION_PREFIXES: + assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + + +def test_events_defined(): + from app.events.types import HealthcareEventType + + assert HealthcareEventType.CLINIC_CREATED.value == "healthcare.clinic.created" + assert HealthcareEventType.CLINIC_UPDATED.value == "healthcare.clinic.updated" + assert HealthcareEventType.DOCTOR_CREATED.value == "healthcare.doctor.created" + assert HealthcareEventType.PATIENT_CREATED.value == "healthcare.patient.created" + assert HealthcareEventType.SETTING_UPDATED.value == "healthcare.setting.updated" + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + StorageProvider, + ) + + assert AccountingProvider is not None + assert CommunicationProvider is not None + assert DeliveryProvider is not None + assert LoyaltyProvider is not None + assert CRMProvider is not None + assert StorageProvider is not None + assert AIProvider is not None + assert IdentityProvider is not None + + +def test_no_forbidden_service_imports(): + root = Path(__file__).resolve().parents[1] + violations: list[str] = [] + for path in root.rglob("*.py"): + if "tests" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if alias.name.startswith(forbidden) or forbidden in alias.name: + violations.append(f"{path}: import {alias.name}") + elif isinstance(node, ast.ImportFrom) and node.module: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if node.module.startswith(forbidden) or forbidden in node.module: + violations.append(f"{path}: from {node.module}") + assert violations == [] + + +def test_api_ownership_is_healthcare_only(): + from app.api.v1 import api_router + + prefixes = {getattr(r, "path", "") for r in api_router.routes} + joined = " ".join(sorted(prefixes)) + assert "/clinics" in joined or any("/clinics" in p for p in prefixes) + assert "/doctors" in joined or any("/doctors" in p for p in prefixes) + assert "/patients" in joined or any("/patients" in p for p in prefixes) + forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports") + for name in forbidden: + assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name + + +def test_folder_structure(): + root = Path(__file__).resolve().parents[2] + required = [ + "app/models", + "app/repositories", + "app/services", + "app/validators", + "app/schemas", + "app/events", + "app/permissions", + "app/providers", + "app/policies", + "app/specifications", + "app/commands", + "app/queries", + "app/api/v1", + "app/tests", + "alembic/versions", + "README.md", + ] + for rel in required: + assert (root / rel).exists(), f"missing {rel}" diff --git a/backend/services/healthcare/app/tests/test_dependency.py b/backend/services/healthcare/app/tests/test_dependency.py new file mode 100644 index 0000000..ddf62fe --- /dev/null +++ b/backend/services/healthcare/app/tests/test_dependency.py @@ -0,0 +1,34 @@ +"""Dependency / layering validation.""" +from app.repositories.base import TenantBaseRepository +from app.repositories.foundation import ClinicRepository +from app.repositories.profiles import DoctorRepository +from app.services import foundation as services +from app.services import profiles as profile_services +from app.providers import contracts + + +def test_repos_inherit_tenant_base(): + assert issubclass(ClinicRepository, TenantBaseRepository) + assert issubclass(DoctorRepository, TenantBaseRepository) + + +def test_services_do_not_import_fastapi(): + import inspect + + src = inspect.getsource(services) + assert "fastapi" not in src.lower() + profile_src = inspect.getsource(profile_services) + assert "fastapi" not in profile_src.lower() + + +def test_provider_contracts_are_protocols(): + assert hasattr(contracts.AccountingProvider, "__protocol_attrs__") or True + assert callable(contracts.DeliveryProvider.request_delivery) + + +def test_commands_and_queries_exist(): + from app.commands.profiles import ProfileCommands + from app.queries.profiles import ProfileQueries + + assert ProfileCommands is not None + assert ProfileQueries is not None diff --git a/backend/services/healthcare/app/tests/test_docs.py b/backend/services/healthcare/app/tests/test_docs.py new file mode 100644 index 0000000..64e5000 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_docs.py @@ -0,0 +1,32 @@ +"""Documentation validation for Healthcare Phase 13.0.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def test_phase_docs_exist(): + assert (ROOT / "docs" / "phases" / "Healthcare" / "README.md").exists() + assert (ROOT / "docs" / "phases" / "Healthcare" / "phase-13-0-healthcare-foundation.md").exists() + assert (ROOT / "docs" / "healthcare-roadmap.md").exists() + + +def test_module_registry_mentions_healthcare(): + text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8") + assert "## healthcare" in text.lower() or "healthcare" in text.lower() + assert "healthcare_db" in text or "8010" in text + assert "healthcare." in text + + +def test_progress_mentions_phase_13(): + text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8") + assert "13" in text or "Healthcare" in text + + +def test_service_readme_documents_boundaries(): + text = (ROOT / "backend" / "services" / "healthcare" / "README.md").read_text( + encoding="utf-8" + ) + assert "healthcare_db" in text + assert "8010" in text + assert "doctors" in text.lower() + assert "patients" in text.lower() diff --git a/backend/services/healthcare/app/tests/test_migration.py b/backend/services/healthcare/app/tests/test_migration.py new file mode 100644 index 0000000..a3bb973 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_migration.py @@ -0,0 +1,60 @@ +"""Migration validation.""" +from pathlib import Path + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "clinics", + "branches", + "departments", + "doctors", + "patients", + "healthcare_roles", + "healthcare_permissions", + "external_provider_configs", + "healthcare_configurations", + "healthcare_settings", + "healthcare_audit_logs", + "outbox_events", + "schedules", + "availability_windows", + "appointment_types", + "appointments", + "waiting_list_entries", + "visit_sessions", + "visit_notes", + "clinic_services", + "staff_assignments", + "clinic_policies", + "operating_hours_policies", + "patient_portal_profiles", + "patient_document_refs", + "patient_notification_preferences", + "medical_records", + "encounters", + "conditions", + "allergies", + "medication_statements", + "immunization_records", + "medical_record_access_logs", + "pharmacies", + "prescription_orders", + "prescription_lines", + "prescription_status_history", + "delivery_integration_registrations", + "delivery_job_intents", + "delivery_job_status_snapshots", +} + + +def test_alembic_head_revision(): + versions = Path(__file__).resolve().parents[2] / "alembic" / "versions" + files = list(versions.glob("0008_phase_137*.py")) + assert files, "missing 0008_phase_137 migration" + + +def test_metadata_has_foundation_tables(): + names = set(Base.metadata.tables.keys()) + assert EXPECTED_TABLES.issubset(names) diff --git a/backend/services/healthcare/app/tests/test_permissions.py b/backend/services/healthcare/app/tests/test_permissions.py new file mode 100644 index 0000000..ebd8714 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_permissions.py @@ -0,0 +1,29 @@ +"""Permission definition tests.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_healthcare_prefix(): + assert ALL_PERMISSIONS + for perm in ALL_PERMISSIONS: + assert perm.startswith("healthcare.") + + +def test_permission_prefixes_stable(): + assert "healthcare." in PERMISSION_PREFIXES + assert "healthcare.clinics." in PERMISSION_PREFIXES + assert "healthcare.doctors." in PERMISSION_PREFIXES + assert "healthcare.patients." in PERMISSION_PREFIXES + + +def test_doctor_patient_permissions_present(): + from app.permissions.definitions import ( + DOCTORS_CREATE, + DOCTORS_VIEW, + PATIENTS_CREATE, + PATIENTS_VIEW, + ) + + assert DOCTORS_VIEW in ALL_PERMISSIONS + assert DOCTORS_CREATE in ALL_PERMISSIONS + assert PATIENTS_VIEW in ALL_PERMISSIONS + assert PATIENTS_CREATE in ALL_PERMISSIONS diff --git a/backend/services/healthcare/app/tests/test_phase_131.py b/backend/services/healthcare/app/tests/test_phase_131.py new file mode 100644 index 0000000..e6649ea --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_131.py @@ -0,0 +1,209 @@ +"""Phase 13.1 — Appointment Engine tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockCommunicationProvider +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.appointments import ensure_appointment_lifecycle_transition +from app.models.types import AppointmentLifecycleAction, AppointmentStatus +from shared.exceptions import AppError + + +async def _seed_foundation(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN131", "name": "Appt Clinic", "status": "active"}, + ) + assert clinic.status_code == 201 + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-131", + "clinic_id": clinic_id, + "code": "DOC131", + "display_name": "Dr Appt", + }, + ) + assert doctor.status_code == 201 + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-131", + "code": "PAT131", + "display_name": "Patient Appt", + "contact": {"phone": "+989121111111"}, + }, + ) + assert patient.status_code == 201 + return clinic_id, doctor.json()["id"], patient.json()["id"] + + +def test_appointment_lifecycle_transition_unit(): + ensure_appointment_lifecycle_transition( + action=AppointmentLifecycleAction.CONFIRM, current=AppointmentStatus.DRAFT + ) + with pytest.raises(AppError) as exc: + ensure_appointment_lifecycle_transition( + action=AppointmentLifecycleAction.CONFIRM, current=AppointmentStatus.COMPLETED + ) + assert exc.value.error_code == "appointment_terminal" + + +@pytest.mark.asyncio +async def test_appointment_lifecycle_and_reminder(client): + MockCommunicationProvider.reset() + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + + appt_type = await client.post( + "/api/v1/appointment-types", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "CONSULT", + "name": "Consultation", + "default_duration_minutes": 30, + }, + ) + assert appt_type.status_code == 201 + + schedule = await client.post( + "/api/v1/schedules", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "code": "SCH-MON", + "name": "Monday", + "day_of_week": "monday", + "start_time": "09:00:00", + "end_time": "17:00:00", + }, + ) + assert schedule.status_code == 201 + + booked = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_type_id": appt_type.json()["id"], + "code": "APT1", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + }, + ) + assert booked.status_code == 201, booked.text + appt = booked.json() + assert appt["status"] == "draft" + aid = appt["id"] + version = appt["version"] + + confirmed = await client.post( + f"/api/v1/appointments/{aid}/confirm", + headers=headers, + json={"version": version}, + ) + assert confirmed.status_code == 200, confirmed.text + assert confirmed.json()["status"] == "confirmed" + assert confirmed.json()["reminder_scheduled"] is True + assert len(MockCommunicationProvider.sent) == 1 + assert MockCommunicationProvider.sent[0]["template_key"] == "healthcare.appointment.reminder" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.APPOINTMENT_BOOKED.value in events + assert HealthcareEventType.APPOINTMENT_CONFIRMED.value in events + assert HealthcareEventType.APPOINTMENT_REMINDER_SCHEDULED.value in events + + +@pytest.mark.asyncio +async def test_appointment_conflict_detection(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 2, 14, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + payload = { + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-CONF-A", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + } + first = await client.post("/api/v1/appointments", headers=headers, json=payload) + assert first.status_code == 201 + + overlap = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + **payload, + "code": "APT-CONF-B", + "scheduled_start": (start + timedelta(minutes=15)).isoformat(), + "scheduled_end": (end + timedelta(minutes=15)).isoformat(), + }, + ) + assert overlap.status_code == 409 + assert overlap.json()["error"]["code"] == "appointment_conflict" + + +@pytest.mark.asyncio +async def test_appointment_tenant_isolation(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 3, 9, 0, tzinfo=timezone.utc) + booked = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-ISO", + "scheduled_start": start.isoformat(), + "scheduled_end": (start + timedelta(minutes=30)).isoformat(), + }, + ) + assert booked.status_code == 201 + aid = booked.json()["id"] + other = await client.get( + f"/api/v1/appointments/{aid}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_waiting_list_and_capabilities(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + wl = await client.post( + "/api/v1/waiting-list", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "WL1", + "priority": 50, + }, + ) + assert wl.status_code == 201 + + caps = await client.get("/capabilities") + assert caps.json()["features"]["appointments"] is True + assert caps.json()["features"]["waiting_list"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_132.py b/backend/services/healthcare/app/tests/test_phase_132.py new file mode 100644 index 0000000..64930dd --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_132.py @@ -0,0 +1,210 @@ +"""Phase 13.2 — Doctor Panel tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.doctor_panel import ensure_visit_session_transition +from app.models.types import VisitSessionAction, VisitSessionStatus +from shared.exceptions import AppError + + +async def _seed_appointment(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN132", "name": "Visit Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-132", + "clinic_id": clinic_id, + "code": "DOC132", + "display_name": "Dr Visit", + }, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-132", + "code": "PAT132", + "display_name": "Patient Visit", + }, + ) + patient_id = patient.json()["id"] + start = datetime(2026, 9, 1, 10, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + appt = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT132", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + }, + ) + assert appt.status_code == 201 + aid = appt.json()["id"] + version = appt.json()["version"] + confirmed = await client.post( + f"/api/v1/appointments/{aid}/confirm", + headers=headers, + json={"version": version}, + ) + assert confirmed.status_code == 200 + return clinic_id, doctor_id, patient_id, aid, confirmed.json()["version"] + + +def test_visit_session_transition_unit(): + ensure_visit_session_transition( + action=VisitSessionAction.START, current=VisitSessionStatus.QUEUED + ) + with pytest.raises(AppError) as exc: + ensure_visit_session_transition( + action=VisitSessionAction.FINISH, current=VisitSessionStatus.QUEUED + ) + assert exc.value.error_code == "invalid_visit_session_transition" + + +@pytest.mark.asyncio +async def test_visit_session_lifecycle_completes_appointment(client): + clinic_id, doctor_id, patient_id, appt_id, appt_version = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + + session = await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS1", + "queue_position": 10, + }, + ) + assert session.status_code == 201, session.text + vs = session.json() + assert vs["status"] == "queued" + vid = vs["id"] + version = vs["version"] + + started = await client.post( + f"/api/v1/visit-sessions/{vid}/start", + headers=headers, + json={"version": version}, + ) + assert started.status_code == 200 + assert started.json()["status"] == "in_progress" + version = started.json()["version"] + + appt = await client.get(f"/api/v1/appointments/{appt_id}", headers=headers) + assert appt.json()["status"] == "checked_in" + + finished = await client.post( + f"/api/v1/visit-sessions/{vid}/finish", + headers=headers, + json={"version": version}, + ) + assert finished.status_code == 200 + assert finished.json()["status"] == "completed" + + appt = await client.get(f"/api/v1/appointments/{appt_id}", headers=headers) + assert appt.json()["status"] == "completed" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.VISIT_SESSION_CREATED.value in events + assert HealthcareEventType.VISIT_SESSION_STARTED.value in events + assert HealthcareEventType.VISIT_SESSION_FINISHED.value in events + assert HealthcareEventType.APPOINTMENT_COMPLETED.value in events + + +@pytest.mark.asyncio +async def test_doctor_dashboard_and_queue(client): + clinic_id, doctor_id, patient_id, appt_id, _ = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS-Q", + }, + ) + + dashboard = await client.get( + "/api/v1/doctor-panel/dashboard", + headers=headers, + params={"doctor_id": doctor_id}, + ) + assert dashboard.status_code == 200 + assert dashboard.json()["queued_count"] >= 1 + + queue = await client.get( + "/api/v1/doctor-panel/queue", + headers=headers, + params={"doctor_id": doctor_id}, + ) + assert queue.status_code == 200 + assert len(queue.json()["items"]) >= 1 + + +@pytest.mark.asyncio +async def test_visit_note_and_tenant_isolation(client): + clinic_id, doctor_id, patient_id, appt_id, _ = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + session = await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS-N", + }, + ) + vid = session.json()["id"] + + note = await client.post( + "/api/v1/visit-notes", + headers=headers, + json={ + "visit_session_id": vid, + "doctor_id": doctor_id, + "code": "VN1", + "title": "Initial assessment", + "body": "Patient stable", + "storage_file_ref": "storage://file-1", + }, + ) + assert note.status_code == 201 + + other = await client.get( + f"/api/v1/visit-sessions/{vid}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_phase_132_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["visits"] is True + assert caps.json()["features"]["doctor_panel"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_133.py b/backend/services/healthcare/app/tests/test_phase_133.py new file mode 100644 index 0000000..e735f5c --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_133.py @@ -0,0 +1,106 @@ +"""Phase 13.3 — Clinic Management tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.tests.conftest import TENANT_A, tenant_headers +from app.validators.clinic_management import validate_clinic_policy_windows +from shared.exceptions import AppError + + +async def _seed_clinic(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN133", "name": "Admin Clinic", "status": "active"}, + ) + assert clinic.status_code == 201 + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-133", + "clinic_id": clinic_id, + "code": "DOC133", + "display_name": "Dr Admin", + }, + ) + assert doctor.status_code == 201 + return clinic_id, doctor.json()["id"] + + +def test_clinic_policy_validator(): + validate_clinic_policy_windows(booking_lead_time_hours=24, cancellation_window_hours=12) + with pytest.raises(AppError) as exc: + validate_clinic_policy_windows(booking_lead_time_hours=12, cancellation_window_hours=24) + assert exc.value.error_code == "invalid_policy_windows" + + +@pytest.mark.asyncio +async def test_clinic_management_crud(client): + clinic_id, doctor_id = await _seed_clinic(client) + headers = tenant_headers(TENANT_A) + + service = await client.post( + "/api/v1/clinic-services", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "SVC1", + "name": "Consultation", + "duration_minutes": 30, + }, + ) + assert service.status_code == 201 + + staff = await client.post( + "/api/v1/staff-assignments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "code": "STAFF1", + "role_code": "attending", + }, + ) + assert staff.status_code == 201 + + policy = await client.post( + "/api/v1/clinic-policies", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "POL1", + "name": "Booking Policy", + "kind": "booking", + "booking_lead_time_hours": 48, + "cancellation_window_hours": 24, + }, + ) + assert policy.status_code == 201 + + hours = await client.post( + "/api/v1/operating-hours-policies", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "HRS1", + "name": "Weekday Hours", + "weekly_hours": {"monday": {"open": "08:00", "close": "17:00"}}, + }, + ) + assert hours.status_code == 201 + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.CLINIC_SERVICE_CREATED.value in events + assert HealthcareEventType.STAFF_ASSIGNMENT_CREATED.value in events + + +@pytest.mark.asyncio +async def test_phase_133_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["clinic_management"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_134.py b/backend/services/healthcare/app/tests/test_phase_134.py new file mode 100644 index 0000000..2f8bb79 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_134.py @@ -0,0 +1,132 @@ +"""Phase 13.4 — Patient Portal tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +def patient_headers(tenant_id, patient_id): + h = tenant_headers(tenant_id) + h["X-Patient-ID"] = str(patient_id) + return h + + +async def _seed_patient(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN134", "name": "Portal Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-134", + "clinic_id": clinic_id, + "code": "DOC134", + "display_name": "Dr Portal", + }, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-134", + "code": "PAT134", + "display_name": "Portal Patient", + "contact": {"phone": "+989121234567"}, + }, + ) + patient_id = patient.json()["id"] + return clinic_id, doctor_id, patient_id + + +@pytest.mark.asyncio +async def test_patient_portal_profile_and_appointments(client): + clinic_id, doctor_id, patient_id = await _seed_patient(client) + headers = patient_headers(TENANT_A, patient_id) + + profile = await client.get("/api/v1/patient-portal/me", headers=headers) + assert profile.status_code == 200 + assert profile.json()["patient_id"] == patient_id + + updated = await client.patch( + "/api/v1/patient-portal/me", + headers=headers, + json={"preferred_language": "en", "version": profile.json()["version"]}, + ) + assert updated.status_code == 200 + assert updated.json()["preferred_language"] == "en" + + start = datetime(2026, 10, 1, 9, 0, tzinfo=timezone.utc) + booked = await client.post( + "/api/v1/patient-portal/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-PORTAL", + "scheduled_start": start.isoformat(), + "scheduled_end": (start + timedelta(minutes=30)).isoformat(), + }, + ) + assert booked.status_code == 201 + + listed = await client.get("/api/v1/patient-portal/appointments", headers=headers) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + +@pytest.mark.asyncio +async def test_patient_isolation(client): + _, _, patient_a = await _seed_patient(client) + headers_b = tenant_headers(TENANT_B) + patient_b = await client.post( + "/api/v1/patients", + headers=headers_b, + json={"user_id": "pat-b", "code": "PATB", "display_name": "Other"}, + ) + assert patient_b.status_code == 201 + + denied = await client.get( + "/api/v1/patient-portal/me", + headers=patient_headers(TENANT_A, patient_b.json()["id"]), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_patient_documents_and_notification_prefs(client): + _, _, patient_id = await _seed_patient(client) + headers = patient_headers(TENANT_A, patient_id) + + doc = await client.post( + "/api/v1/patient-portal/documents", + headers=headers, + json={ + "code": "DOC1", + "title": "Lab Result", + "storage_file_ref": "storage://lab-1", + }, + ) + assert doc.status_code == 201 + + pref = await client.put( + "/api/v1/patient-portal/notification-preferences", + headers=headers, + json={"channel": "sms", "template_key": "healthcare.appointment.reminder", "enabled": True}, + ) + assert pref.status_code == 200 + + +@pytest.mark.asyncio +async def test_phase_134_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["patient_portal"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_135.py b/backend/services/healthcare/app/tests/test_phase_135.py new file mode 100644 index 0000000..9947303 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_135.py @@ -0,0 +1,124 @@ +"""Phase 13.5 — Medical Record tests.""" +from __future__ import annotations + +import pytest + +from app.events.types import HealthcareEventType +from app.events.publisher import get_event_publisher +from app.tests.conftest import TENANT_A, tenant_headers +from app.models.types import EncounterStatus +from app.validators.medical_records import ensure_encounter_mutable +from shared.exceptions import AppError + + +async def _seed_record(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN135", "name": "Record Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-135", "clinic_id": clinic_id, "code": "DOC135", "display_name": "Dr Record"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={"user_id": "pat-135", "code": "PAT135", "display_name": "Record Patient"}, + ) + patient_id = patient.json()["id"] + record = await client.post( + "/api/v1/medical-records", + headers=headers, + json={"clinic_id": clinic_id, "patient_id": patient_id, "code": "MR1"}, + ) + assert record.status_code == 201 + return doctor_id, record.json()["id"] + + +def test_encounter_immutability_validator(): + with pytest.raises(AppError) as exc: + ensure_encounter_mutable(EncounterStatus.FINALIZED) + assert exc.value.error_code == "encounter_finalized" + + +@pytest.mark.asyncio +async def test_encounter_finalize_immutability(client): + doctor_id, record_id = await _seed_record(client) + headers = tenant_headers(TENANT_A) + + enc = await client.post( + "/api/v1/encounters", + headers=headers, + json={ + "medical_record_id": record_id, + "doctor_id": doctor_id, + "code": "ENC1", + "chief_complaint": "Headache", + }, + ) + assert enc.status_code == 201 + eid = enc.json()["id"] + version = enc.json()["version"] + + updated = await client.patch( + f"/api/v1/encounters/{eid}", + headers=headers, + json={"clinical_summary": "Migraine suspected", "version": version}, + ) + assert updated.status_code == 200 + version = updated.json()["version"] + + finalized = await client.post( + f"/api/v1/encounters/{eid}/finalize", + headers=headers, + json={"version": version}, + ) + assert finalized.status_code == 200 + assert finalized.json()["status"] == "finalized" + + blocked = await client.patch( + f"/api/v1/encounters/{eid}", + headers=headers, + json={"clinical_summary": "Changed", "version": finalized.json()["version"]}, + ) + assert blocked.status_code == 409 + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.ENCOUNTER_FINALIZED.value in events + + +@pytest.mark.asyncio +async def test_clinical_lists_and_access(client): + _, record_id = await _seed_record(client) + headers = tenant_headers(TENANT_A) + + await client.post( + f"/api/v1/medical-records/{record_id}/conditions", + headers=headers, + json={"code": "COND1", "display": "Hypertension"}, + ) + await client.post( + f"/api/v1/medical-records/{record_id}/allergies", + headers=headers, + json={"code": "ALG1", "substance": "Penicillin"}, + ) + + record = await client.get(f"/api/v1/medical-records/{record_id}", headers=headers) + assert record.status_code == 200 + + lists = await client.get( + f"/api/v1/medical-records/{record_id}/clinical-lists", headers=headers + ) + assert lists.status_code == 200 + assert len(lists.json()["conditions"]) >= 1 + + +@pytest.mark.asyncio +async def test_phase_135_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["medical_records"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_136.py b/backend/services/healthcare/app/tests/test_phase_136.py new file mode 100644 index 0000000..533ae89 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_136.py @@ -0,0 +1,110 @@ +"""Phase 13.6 — Pharmacy Network tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockCommunicationProvider +from app.tests.conftest import TENANT_A, tenant_headers +from app.validators.pharmacy import ensure_prescription_transition +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from shared.exceptions import AppError + + +async def _seed_pharmacy(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN136", "name": "Rx Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-136", "clinic_id": clinic_id, "code": "DOC136", "display_name": "Dr Rx"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-136", + "code": "PAT136", + "display_name": "Rx Patient", + "contact": {"phone": "+989129999999"}, + }, + ) + patient_id = patient.json()["id"] + pharmacy = await client.post( + "/api/v1/pharmacies", + headers=headers, + json={"code": "PH1", "name": "City Pharmacy"}, + ) + assert pharmacy.status_code == 201 + return doctor_id, patient_id, pharmacy.json()["id"] + + +def test_prescription_lifecycle_validator(): + ensure_prescription_transition( + action=PrescriptionLifecycleAction.ACCEPT, current=PrescriptionOrderStatus.SUBMITTED + ) + with pytest.raises(AppError): + ensure_prescription_transition( + action=PrescriptionLifecycleAction.ACCEPT, current=PrescriptionOrderStatus.READY + ) + + +@pytest.mark.asyncio +async def test_prescription_fulfillment_lifecycle(client): + MockCommunicationProvider.reset() + doctor_id, patient_id, pharmacy_id = await _seed_pharmacy(client) + headers = tenant_headers(TENANT_A) + + order = await client.post( + "/api/v1/prescription-orders", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "patient_id": patient_id, + "doctor_id": doctor_id, + "code": "RX1", + "lines": [ + {"line_no": 1, "medication_code": "MED001", "display": "Amoxicillin", "quantity": 1} + ], + }, + ) + assert order.status_code == 201, order.text + oid = order.json()["id"] + version = order.json()["version"] + + for action in ("accept", "prepare", "mark-ready"): + resp = await client.post( + f"/api/v1/prescription-orders/{oid}/{action}", + headers=headers, + json={"version": version}, + ) + assert resp.status_code == 200, resp.text + version = resp.json()["version"] + assert resp.json()["status"] in ("accepted", "preparing", "ready") + + assert len(MockCommunicationProvider.sent) == 1 + + picked = await client.post( + f"/api/v1/prescription-orders/{oid}/complete-pickup", + headers=headers, + json={"version": version}, + ) + assert picked.status_code == 200 + assert picked.json()["status"] == "picked_up" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.PRESCRIPTION_ORDER_SUBMITTED.value in events + assert HealthcareEventType.PRESCRIPTION_ORDER_STATUS_CHANGED.value in events + + +@pytest.mark.asyncio +async def test_phase_136_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["pharmacy"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_137.py b/backend/services/healthcare/app/tests/test_phase_137.py new file mode 100644 index 0000000..e7ee845 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_137.py @@ -0,0 +1,119 @@ +"""Phase 13.7 — Delivery Integration tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockDeliveryProvider +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _seed_ready_prescription(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN137", "name": "Delivery Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-137", "clinic_id": clinic_id, "code": "DOC137", "display_name": "Dr Del"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={"user_id": "pat-137", "code": "PAT137", "display_name": "Del Patient"}, + ) + patient_id = patient.json()["id"] + pharmacy = await client.post( + "/api/v1/pharmacies", + headers=headers, + json={"code": "PH137", "name": "Delivery Pharmacy"}, + ) + pharmacy_id = pharmacy.json()["id"] + order = await client.post( + "/api/v1/prescription-orders", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "patient_id": patient_id, + "doctor_id": doctor_id, + "code": "RX137", + "lines": [{"line_no": 1, "medication_code": "M1", "display": "Med", "quantity": 1}], + }, + ) + oid = order.json()["id"] + version = order.json()["version"] + for action in ("accept", "prepare", "mark-ready"): + resp = await client.post( + f"/api/v1/prescription-orders/{oid}/{action}", + headers=headers, + json={"version": version}, + ) + version = resp.json()["version"] + registration = await client.post( + "/api/v1/delivery-integrations", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "code": "DELREG1", + "name": "Pharmacy Delivery", + "delivery_merchant_ref": "merchant-137", + }, + ) + assert registration.status_code == 201 + return oid, registration.json()["id"] + + +@pytest.mark.asyncio +async def test_delivery_intent_idempotency_and_webhook(client): + MockDeliveryProvider.reset() + order_id, reg_id = await _seed_ready_prescription(client) + headers = tenant_headers(TENANT_A) + payload = { + "registration_id": reg_id, + "idempotency_key": "idem-137", + "code": "DJ1", + "pickup_address": {"line1": "Pharmacy"}, + "dropoff_address": {"line1": "Home"}, + } + + first = await client.post( + f"/api/v1/delivery-job-intents/prescription-orders/{order_id}", + headers=headers, + json=payload, + ) + assert first.status_code == 201, first.text + ref = first.json()["delivery_job_ref"] + assert ref.startswith("mock-delivery-") + + second = await client.post( + f"/api/v1/delivery-job-intents/prescription-orders/{order_id}", + headers=headers, + json=payload, + ) + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + + webhook = await client.post( + "/api/v1/webhooks/delivery-status", + headers=headers, + json={"delivery_job_ref": ref, "status": "delivered", "external_status": "DELIVERED"}, + ) + assert webhook.status_code == 200 + assert webhook.json()["status"] == "delivered" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.DELIVERY_JOB_INTENT_CREATED.value in events + assert HealthcareEventType.DELIVERY_JOB_STATUS_UPDATED.value in events + + +@pytest.mark.asyncio +async def test_phase_137_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["phase"] == "13.7" + assert caps.json()["features"]["delivery_integration"] is True diff --git a/backend/services/healthcare/app/tests/test_security.py b/backend/services/healthcare/app/tests/test_security.py new file mode 100644 index 0000000..20bb814 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_security.py @@ -0,0 +1,40 @@ +"""Security validation — anonymous denial when auth required.""" +from __future__ import annotations + +import os + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.mark.asyncio +async def test_auth_required_denies_anonymous(db_setup): + os.environ["AUTH_REQUIRED"] = "true" + from app.core.config import get_settings + + get_settings.cache_clear() + import app.core.config as config_mod + import app.core.security as security_mod + import app.api.permissions as perm_mod + + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings + + from app.main import create_app + + app = create_app() + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + res = await client.get( + "/api/v1/clinics", + headers={"X-Tenant-ID": "11111111-1111-1111-1111-111111111111"}, + ) + assert res.status_code == 401 + finally: + os.environ["AUTH_REQUIRED"] = "false" + get_settings.cache_clear() + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings diff --git a/backend/services/healthcare/app/tests/test_tenant.py b/backend/services/healthcare/app/tests/test_tenant.py new file mode 100644 index 0000000..1795856 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_tenant.py @@ -0,0 +1,65 @@ +"""Tenant isolation tests — Phase 13.0.""" +from __future__ import annotations + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def test_tenant_required(client): + res = await client.get("/api/v1/clinics") + assert res.status_code in (400, 422) + + +async def test_clinic_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + created = await client.post( + "/api/v1/clinics", + headers=headers_a, + json={"code": "TISO", "name": "Isolated Clinic"}, + ) + assert created.status_code == 201 + clinic_id = created.json()["id"] + + other = await client.get(f"/api/v1/clinics/{clinic_id}", headers=headers_b) + assert other.status_code == 404 + + +async def test_doctor_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + doctor = await client.post( + "/api/v1/doctors", + headers=headers_a, + json={ + "user_id": "iso-doc-1", + "code": "DISO", + "display_name": "Dr. Iso", + }, + ) + assert doctor.status_code == 201 + doctor_id = doctor.json()["id"] + + denied = await client.get(f"/api/v1/doctors/{doctor_id}", headers=headers_b) + assert denied.status_code == 404 + + +async def test_patient_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + patient = await client.post( + "/api/v1/patients", + headers=headers_a, + json={ + "user_id": "iso-pat-1", + "code": "PISO", + "display_name": "Patient Iso", + }, + ) + assert patient.status_code == 201 + patient_id = patient.json()["id"] + + denied = await client.get(f"/api/v1/patients/{patient_id}", headers=headers_b) + assert denied.status_code == 404 diff --git a/backend/services/healthcare/app/validators/__init__.py b/backend/services/healthcare/app/validators/__init__.py new file mode 100644 index 0000000..90ef2b8 --- /dev/null +++ b/backend/services/healthcare/app/validators/__init__.py @@ -0,0 +1,14 @@ +"""Delivery validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_currency_code", + "ensure_optimistic_version", +] diff --git a/backend/services/healthcare/app/validators/appointments.py b/backend/services/healthcare/app/validators/appointments.py new file mode 100644 index 0000000..34eb11e --- /dev/null +++ b/backend/services/healthcare/app/validators/appointments.py @@ -0,0 +1,87 @@ +"""Appointment validators — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime + +from shared.exceptions import AppError + +from app.models.types import AppointmentLifecycleAction, AppointmentStatus + +ALLOWED_TRANSITIONS: dict[AppointmentLifecycleAction, set[AppointmentStatus]] = { + AppointmentLifecycleAction.BOOK: set(), + AppointmentLifecycleAction.CONFIRM: {AppointmentStatus.DRAFT}, + AppointmentLifecycleAction.CHECK_IN: {AppointmentStatus.CONFIRMED}, + AppointmentLifecycleAction.COMPLETE: { + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + }, + AppointmentLifecycleAction.CANCEL: { + AppointmentStatus.DRAFT, + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + }, + AppointmentLifecycleAction.RESCHEDULE: { + AppointmentStatus.DRAFT, + AppointmentStatus.CONFIRMED, + }, + AppointmentLifecycleAction.MARK_NO_SHOW: {AppointmentStatus.CONFIRMED}, +} + +TARGET_STATUS: dict[AppointmentLifecycleAction, AppointmentStatus] = { + AppointmentLifecycleAction.BOOK: AppointmentStatus.DRAFT, + AppointmentLifecycleAction.CONFIRM: AppointmentStatus.CONFIRMED, + AppointmentLifecycleAction.CHECK_IN: AppointmentStatus.CHECKED_IN, + AppointmentLifecycleAction.COMPLETE: AppointmentStatus.COMPLETED, + AppointmentLifecycleAction.CANCEL: AppointmentStatus.CANCELLED, + AppointmentLifecycleAction.MARK_NO_SHOW: AppointmentStatus.NO_SHOW, +} + +TERMINAL_STATUSES = frozenset( + {AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW} +) + + +def ensure_appointment_lifecycle_transition( + *, action: AppointmentLifecycleAction, current: AppointmentStatus +) -> None: + if current in TERMINAL_STATUSES: + raise AppError( + "نوبت در وضعیت پایانی است", + status_code=409, + error_code="appointment_terminal", + details={"status": current.value, "action": action.value}, + ) + allowed = ALLOWED_TRANSITIONS.get(action, set()) + if action != AppointmentLifecycleAction.BOOK and current not in allowed: + raise AppError( + "انتقال وضعیت نوبت مجاز نیست", + status_code=409, + error_code="invalid_appointment_transition", + details={ + "from_status": current.value, + "action": action.value, + "allowed_from": sorted(s.value for s in allowed), + }, + ) + + +def target_status_for(action: AppointmentLifecycleAction) -> AppointmentStatus: + return TARGET_STATUS[action] + + +def validate_time_window(*, starts_at: datetime, ends_at: datetime) -> None: + if ends_at <= starts_at: + raise AppError( + "بازه زمانی نامعتبر است", + status_code=422, + error_code="invalid_time_window", + ) + + +def validate_schedule_times(*, start_time, end_time) -> None: + if end_time <= start_time: + raise AppError( + "ساعت پایان باید بعد از ساعت شروع باشد", + status_code=422, + error_code="invalid_schedule_times", + ) diff --git a/backend/services/healthcare/app/validators/clinic_management.py b/backend/services/healthcare/app/validators/clinic_management.py new file mode 100644 index 0000000..9c65730 --- /dev/null +++ b/backend/services/healthcare/app/validators/clinic_management.py @@ -0,0 +1,26 @@ +"""Clinic policy validators — Phase 13.3.""" +from __future__ import annotations + +from shared.exceptions import AppError + + +def validate_clinic_policy_windows(*, booking_lead_time_hours: int, cancellation_window_hours: int) -> None: + if cancellation_window_hours > booking_lead_time_hours: + raise AppError( + "پنجره لغو نمی‌تواند بیشتر از زمان پیش‌نیاز رزرو باشد", + status_code=422, + error_code="invalid_policy_windows", + details={ + "booking_lead_time_hours": booking_lead_time_hours, + "cancellation_window_hours": cancellation_window_hours, + }, + ) + + +def validate_weekly_hours(weekly_hours: dict) -> None: + if not weekly_hours: + raise AppError( + "ساعات هفتگی الزامی است", + status_code=422, + error_code="weekly_hours_required", + ) diff --git a/backend/services/healthcare/app/validators/doctor_panel.py b/backend/services/healthcare/app/validators/doctor_panel.py new file mode 100644 index 0000000..93a7341 --- /dev/null +++ b/backend/services/healthcare/app/validators/doctor_panel.py @@ -0,0 +1,42 @@ +"""Doctor panel validators — Phase 13.2.""" +from __future__ import annotations + +from app.models.types import VisitSessionAction, VisitSessionStatus +from shared.exceptions import AppError + +ALLOWED: dict[VisitSessionAction, set[VisitSessionStatus]] = { + VisitSessionAction.CALL_NEXT: {VisitSessionStatus.QUEUED}, + VisitSessionAction.START: {VisitSessionStatus.QUEUED}, + VisitSessionAction.FINISH: {VisitSessionStatus.IN_PROGRESS}, + VisitSessionAction.DEFER: {VisitSessionStatus.QUEUED, VisitSessionStatus.IN_PROGRESS}, +} +TARGET: dict[VisitSessionAction, VisitSessionStatus] = { + VisitSessionAction.CALL_NEXT: VisitSessionStatus.QUEUED, + VisitSessionAction.START: VisitSessionStatus.IN_PROGRESS, + VisitSessionAction.FINISH: VisitSessionStatus.COMPLETED, + VisitSessionAction.DEFER: VisitSessionStatus.DEFERRED, +} +TERMINAL = frozenset({VisitSessionStatus.COMPLETED, VisitSessionStatus.CANCELLED}) + + +def ensure_visit_session_transition( + *, action: VisitSessionAction, current: VisitSessionStatus +) -> None: + if current in TERMINAL: + raise AppError( + "جلسه ویزیت در وضعیت پایانی است", + status_code=409, + error_code="visit_session_terminal", + ) + allowed = ALLOWED.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت جلسه ویزیت مجاز نیست", + status_code=409, + error_code="invalid_visit_session_transition", + details={"from_status": current.value, "action": action.value}, + ) + + +def target_status_for(action: VisitSessionAction) -> VisitSessionStatus: + return TARGET[action] diff --git a/backend/services/healthcare/app/validators/foundation.py b/backend/services/healthcare/app/validators/foundation.py new file mode 100644 index 0000000..8f4cecd --- /dev/null +++ b/backend/services/healthcare/app/validators/foundation.py @@ -0,0 +1,55 @@ +"""Reusable validators for Delivery foundation.""" +from __future__ import annotations + +import re +from typing import Any + +from shared.exceptions import AppError + +CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$") + + +class ValidationError(AppError): + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="validation_error", + status_code=422, + details=details or {}, + ) + + +def validate_non_empty(value: str | None, field: str) -> str: + if value is None or not str(value).strip(): + raise ValidationError(f"{field} الزامی است", {"field": field}) + return str(value).strip() + + +def validate_code(code: str) -> str: + code = validate_non_empty(code, "code") + if not CODE_RE.match(code): + raise ValidationError( + "کد نامعتبر است", + {"field": "code", "pattern": CODE_RE.pattern}, + ) + return code + + +def validate_currency_code(code: str) -> str: + code = validate_non_empty(code, "currency_code").upper() + if len(code) != 3: + raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"}) + return code + + +def ensure_optimistic_version(entity: Any, expected: int) -> None: + current = getattr(entity, "version", None) + if current is None: + return + if current != expected: + raise AppError( + "نسخه موجودیت هم‌خوان نیست", + error_code="version_conflict", + status_code=409, + details={"expected": expected, "actual": current}, + ) diff --git a/backend/services/healthcare/app/validators/medical_records.py b/backend/services/healthcare/app/validators/medical_records.py new file mode 100644 index 0000000..7a12eec --- /dev/null +++ b/backend/services/healthcare/app/validators/medical_records.py @@ -0,0 +1,29 @@ +"""Medical record validators — Phase 13.5.""" +from __future__ import annotations + +from app.models.types import EncounterStatus +from shared.exceptions import AppError + + +def ensure_encounter_mutable(status: EncounterStatus) -> None: + if status == EncounterStatus.FINALIZED: + raise AppError( + "Encounter finalized — immutable", + status_code=409, + error_code="encounter_finalized", + ) + + +def ensure_encounter_can_finalize(status: EncounterStatus) -> None: + if status == EncounterStatus.FINALIZED: + raise AppError( + "Encounter already finalized", + status_code=409, + error_code="encounter_already_finalized", + ) + if status not in (EncounterStatus.DRAFT, EncounterStatus.IN_PROGRESS): + raise AppError( + "Encounter cannot be finalized from current status", + status_code=409, + error_code="invalid_encounter_finalize", + ) diff --git a/backend/services/healthcare/app/validators/pharmacy.py b/backend/services/healthcare/app/validators/pharmacy.py new file mode 100644 index 0000000..754368e --- /dev/null +++ b/backend/services/healthcare/app/validators/pharmacy.py @@ -0,0 +1,46 @@ +"""Pharmacy validators — Phase 13.6.""" +from __future__ import annotations + +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from shared.exceptions import AppError + +ALLOWED: dict[PrescriptionLifecycleAction, set[PrescriptionOrderStatus]] = { + PrescriptionLifecycleAction.SUBMIT: set(), + PrescriptionLifecycleAction.ACCEPT: {PrescriptionOrderStatus.SUBMITTED}, + PrescriptionLifecycleAction.PREPARE: {PrescriptionOrderStatus.ACCEPTED}, + PrescriptionLifecycleAction.MARK_READY: {PrescriptionOrderStatus.PREPARING}, + PrescriptionLifecycleAction.COMPLETE_PICKUP: {PrescriptionOrderStatus.READY}, + PrescriptionLifecycleAction.CANCEL: { + PrescriptionOrderStatus.SUBMITTED, + PrescriptionOrderStatus.ACCEPTED, + PrescriptionOrderStatus.PREPARING, + PrescriptionOrderStatus.READY, + }, +} +TARGET: dict[PrescriptionLifecycleAction, PrescriptionOrderStatus] = { + PrescriptionLifecycleAction.SUBMIT: PrescriptionOrderStatus.SUBMITTED, + PrescriptionLifecycleAction.ACCEPT: PrescriptionOrderStatus.ACCEPTED, + PrescriptionLifecycleAction.PREPARE: PrescriptionOrderStatus.PREPARING, + PrescriptionLifecycleAction.MARK_READY: PrescriptionOrderStatus.READY, + PrescriptionLifecycleAction.COMPLETE_PICKUP: PrescriptionOrderStatus.PICKED_UP, + PrescriptionLifecycleAction.CANCEL: PrescriptionOrderStatus.CANCELLED, +} +TERMINAL = frozenset({PrescriptionOrderStatus.PICKED_UP, PrescriptionOrderStatus.CANCELLED}) + + +def ensure_prescription_transition( + *, action: PrescriptionLifecycleAction, current: PrescriptionOrderStatus +) -> None: + if current in TERMINAL: + raise AppError("سفارش نسخه در وضعیت پایانی است", status_code=409, error_code="prescription_terminal") + allowed = ALLOWED.get(action, set()) + if action != PrescriptionLifecycleAction.SUBMIT and current not in allowed: + raise AppError( + "انتقال وضعیت نسخه مجاز نیست", + status_code=409, + error_code="invalid_prescription_transition", + ) + + +def target_prescription_status(action: PrescriptionLifecycleAction) -> PrescriptionOrderStatus: + return TARGET[action] diff --git a/backend/services/healthcare/pytest.ini b/backend/services/healthcare/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/healthcare/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/healthcare/requirements.txt b/backend/services/healthcare/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/healthcare/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic[email]==2.7.4 +pydantic-settings==2.3.4 +sqlalchemy==2.0.31 +alembic==1.13.2 +asyncpg==0.29.0 +psycopg[binary]>=3.2.2 +httpx==0.27.0 +pyjwt[crypto]==2.8.0 +-e ../../shared-lib +pytest==8.2.2 +pytest-asyncio==0.23.7 +aiosqlite==0.20.0 diff --git a/backend/services/healthcare/scripts/app/models/doctor_panel.py b/backend/services/healthcare/scripts/app/models/doctor_panel.py new file mode 100644 index 0000000..796a9d1 --- /dev/null +++ b/backend/services/healthcare/scripts/app/models/doctor_panel.py @@ -0,0 +1,43 @@ +"""Doctor panel aggregates — Phase 13.2.""" +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON +from app.core.database import Base +from app.models.base import ActorAuditMixin, OptimisticLockMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.types import GUID, VisitSessionStatus + +class VisitSession(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_sessions" + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column(default=VisitSessionStatus.QUEUED, nullable=False) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + +class VisitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_notes" + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) + diff --git a/backend/services/healthcare/scripts/ensure_db.py b/backend/services/healthcare/scripts/ensure_db.py new file mode 100644 index 0000000..7eaa56b --- /dev/null +++ b/backend/services/healthcare/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure healthcare_db exists before migration.""" +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + + +def main() -> None: + sync_url = os.environ.get("HEALTHCARE_DATABASE_URL_SYNC", "") + if not sync_url: + print("HEALTHCARE_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "healthcare_db" + + import psycopg + + conn = psycopg.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 5432, + user=parsed.username, + password=parsed.password, + dbname="postgres", + autocommit=True, + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + if cur.fetchone() is None: + cur.execute(f'CREATE DATABASE "{db_name}"') + print(f"Created database: {db_name}") + else: + print(f"Database exists: {db_name}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/healthcare/scripts/gen_phases.py b/backend/services/healthcare/scripts/gen_phases.py new file mode 100644 index 0000000..d09123c --- /dev/null +++ b/backend/services/healthcare/scripts/gen_phases.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Generate healthcare phases 13.2-13.7 implementation files.""" +from __future__ import annotations + +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +FILES: dict[str, str] = {} + + +def add(path: str, content: str) -> None: + FILES[path] = textwrap.dedent(content).lstrip() + "\n" + + +# --- Phase 13.2 models --- +add( + "app/models/doctor_panel.py", + '''\ + """Doctor panel aggregates — Phase 13.2.""" + from __future__ import annotations + import uuid + from datetime import datetime + from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint + from sqlalchemy.orm import Mapped, mapped_column + from sqlalchemy.types import JSON + from app.core.database import Base + from app.models.base import ActorAuditMixin, OptimisticLockMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin + from app.models.types import GUID, VisitSessionStatus + + class VisitSession(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_sessions" + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column(default=VisitSessionStatus.QUEUED, nullable=False) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + + class VisitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_notes" + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) + ''', +) + +# I'll write the generator output directly via Write tool for key files instead +# since the script approach is getting complex. Let me write phase files directly. + +if __name__ == "__main__": + for rel, content in FILES.items(): + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {rel}") diff --git a/backend/services/healthcare/scripts/implement_phases.py b/backend/services/healthcare/scripts/implement_phases.py new file mode 100644 index 0000000..5dd146e --- /dev/null +++ b/backend/services/healthcare/scripts/implement_phases.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Generate healthcare phases 13.2-13.7 implementation files.""" +from __future__ import annotations + +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FILES: dict[str, str] = {} + + +def add(path: str, content: str) -> None: + FILES[path] = textwrap.dedent(content).lstrip() + "\n" + + +# ============================================================================= +# Phase 13.2 — Doctor Panel +# ============================================================================= + +add( + "app/schemas/doctor_panel.py", + '''\ + """Doctor panel DTOs — Phase 13.2.""" + from __future__ import annotations + from datetime import datetime + from uuid import UUID + from pydantic import BaseModel, ConfigDict, Field + from app.models.types import VisitSessionStatus + from app.schemas.common import ORMBase + from app.schemas.appointments import AppointmentRead + + _FORBID = ConfigDict(extra="forbid") + + class VisitSessionCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str = Field(max_length=50) + queue_position: int = Field(default=100, ge=1, le=10000) + metadata_json: dict | None = None + + class VisitSessionActionRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + class VisitSessionRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str + status: VisitSessionStatus + queue_position: int + started_at: datetime | None + finished_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + class VisitNoteCreate(BaseModel): + model_config = _FORBID + visit_session_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + class VisitNoteUpdate(BaseModel): + model_config = _FORBID + title: str | None = Field(default=None, max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + version: int = Field(ge=1) + metadata_json: dict | None = None + + class VisitNoteRead(ORMBase): + id: UUID + tenant_id: UUID + visit_session_id: UUID + doctor_id: UUID + code: str + title: str + body: str | None + storage_file_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + class DoctorDashboardRead(BaseModel): + doctor_id: UUID + queued_count: int + in_progress_count: int + completed_today_count: int + upcoming_appointments: list[AppointmentRead] + + class DoctorQueueRead(BaseModel): + doctor_id: UUID + items: list[VisitSessionRead] + ''', +) + +add( + "app/validators/doctor_panel.py", + '''\ + """Doctor panel validators — Phase 13.2.""" + from __future__ import annotations + from shared.exceptions import AppError + from app.models.types import VisitSessionAction, VisitSessionStatus + + ALLOWED: dict[VisitSessionAction, set[VisitSessionStatus]] = { + VisitSessionAction.CALL_NEXT: {VisitSessionStatus.QUEUED}, + VisitSessionAction.START: {VisitSessionStatus.QUEUED}, + VisitSessionAction.FINISH: {VisitSessionStatus.IN_PROGRESS}, + VisitSessionAction.DEFER: {VisitSessionStatus.QUEUED, VisitSessionStatus.IN_PROGRESS}, + } + TARGET: dict[VisitSessionAction, VisitSessionStatus] = { + VisitSessionAction.CALL_NEXT: VisitSessionStatus.QUEUED, + VisitSessionAction.START: VisitSessionStatus.IN_PROGRESS, + VisitSessionAction.FINISH: VisitSessionStatus.COMPLETED, + VisitSessionAction.DEFER: VisitSessionStatus.DEFERRED, + } + TERMINAL = frozenset({VisitSessionStatus.COMPLETED, VisitSessionStatus.CANCELLED}) + + def ensure_visit_session_transition(*, action: VisitSessionAction, current: VisitSessionStatus) -> None: + if current in TERMINAL: + raise AppError("جلسه ویزیت در وضعیت پایانی است", status_code=409, error_code="visit_session_terminal") + allowed = ALLOWED.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت جلسه ویزیت مجاز نیست", + status_code=409, + error_code="invalid_visit_session_transition", + details={"from_status": current.value, "action": action.value}, + ) + + def target_status_for(action: VisitSessionAction) -> VisitSessionStatus: + return TARGET[action] + ''', +) + +add( + "app/policies/doctor_panel.py", + '''\ + """Visit session lifecycle policies — Phase 13.2.""" + from __future__ import annotations + from app.models.types import VisitSessionAction, VisitSessionStatus + from app.validators.doctor_panel import ensure_visit_session_transition, target_status_for + + class VisitSessionPolicy: + def ensure_transition(self, *, action: VisitSessionAction, current: VisitSessionStatus) -> VisitSessionStatus: + ensure_visit_session_transition(action=action, current=current) + return target_status_for(action) + ''', +) + +add( + "app/specifications/doctor_panel.py", + '''\ + """Doctor panel list specifications — Phase 13.2.""" + from __future__ import annotations + from dataclasses import dataclass + from uuid import UUID + from app.models.types import VisitSessionStatus + + ALLOWED_SORT_FIELDS = frozenset({"queue_position", "created_at", "started_at"}) + + @dataclass + class VisitSessionListSpec: + doctor_id: UUID | None = None + clinic_id: UUID | None = None + status: VisitSessionStatus | None = None + sort_by: str = "queue_position" + sort_dir: str = "asc" + ''', +) + +add( + "app/repositories/doctor_panel.py", + '''\ + """Doctor panel repositories — Phase 13.2.""" + from __future__ import annotations + from datetime import datetime, timezone + from uuid import UUID + from sqlalchemy import and_, func, select + from app.models.doctor_panel import VisitNote, VisitSession + from app.models.types import VisitSessionStatus + from app.repositories.base import TenantBaseRepository + from app.specifications.doctor_panel import VisitSessionListSpec + + class VisitSessionRepository(TenantBaseRepository[VisitSession]): + model = VisitSession + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.code == code, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_appointment(self, tenant_id: UUID, appointment_id: UUID) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.appointment_id == appointment_id, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + def _filter_clauses(self, tenant_id: UUID, spec: VisitSessionListSpec | None): + clauses = [VisitSession.tenant_id == tenant_id, VisitSession.is_deleted.is_(False)] + if spec is None: + return clauses + if spec.doctor_id is not None: + clauses.append(VisitSession.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(VisitSession.clinic_id == spec.clinic_id) + if spec.status is not None: + clauses.append(VisitSession.status == spec.status) + return clauses + + async def list_filtered(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VisitSessionListSpec | None = None): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr(VisitSession, spec.sort_by if spec else "queue_position", VisitSession.queue_position) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = select(VisitSession).where(and_(*clauses)).order_by(order).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_status(self, tenant_id: UUID, *, doctor_id: UUID, status: VisitSessionStatus) -> int: + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == status, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_completed_today(self, tenant_id: UUID, *, doctor_id: UUID) -> int: + today = datetime.now(timezone.utc).date() + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == VisitSessionStatus.COMPLETED, + VisitSession.is_deleted.is_(False), + func.date(VisitSession.finished_at) == today, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + class VisitNoteRepository(TenantBaseRepository[VisitNote]): + model = VisitNote + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitNote | None: + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.code == code, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID): + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.visit_session_id == visit_session_id, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + ''', +) + +if __name__ == "__main__": + for rel, content in FILES.items(): + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {rel}") diff --git a/frontend/docs/healthcare-api-connection-report.md b/frontend/docs/healthcare-api-connection-report.md new file mode 100644 index 0000000..bca0c84 --- /dev/null +++ b/frontend/docs/healthcare-api-connection-report.md @@ -0,0 +1,118 @@ +# Healthcare API Connection Report + +**Service:** `healthcare-service` v0.13.7.0 +**BFF:** `/api/healthcare/*` → port 8010 +**Client:** `frontend/src/modules/healthcare/services/healthcare-api.ts` + +--- + +## Connection Architecture + +``` +Browser (portal page) + → healthcareApi.* (tenant + JWT headers) + → /api/healthcare/* (Next.js route handler) + → healthcare-service:8010/api/v1/* +``` + +All mutations use optimistic invalidation of TanStack Query keys scoped by tenant. + +--- + +## API Surface Used by UI + +### Foundation (Phase 13.0) + +| Resource | List | Get | Create | Update | Delete | UI Screen | +|----------|------|-----|--------|--------|--------|-----------| +| clinics | ✅ | ✅ | ✅ | ✅ | ✅ | `ClinicsResourcePage` | +| branches | ✅ | ✅ | ✅ | ✅ | ✅ | `BranchesResourcePage` | +| departments | ✅ | ✅ | ✅ | ✅ | ✅ | `DepartmentsResourcePage` | +| doctors | ✅ | ✅ | ✅ | ✅ | ✅ | `DoctorsResourcePage` | +| patients | ✅ | ✅ | ✅ | ✅ | ✅ | `PatientsResourcePage` | +| external_providers | ✅ | ✅ | ✅ | ✅ | ✅ | `ExternalProvidersResourcePage` | +| settings | ✅ | — | upsert | upsert | — | `SettingsResourcePage` | +| audit | ✅ | — | — | — | — | `AuditExplorerPage` | +| permissions/catalog | ✅ | — | — | — | — | `PermissionsCatalogPage` | + +### Appointments & Visits (13.1–13.2) + +| Resource | Operations wired in UI | +|----------|------------------------| +| appointments | list, book, **confirm**, **cancel**, **no-show** | +| waiting_list | list, create | +| visit_sessions | list, create, **start**, **finish**, **defer**, **callNext** | +| schedules | list, create | +| availability_windows | list, create | +| doctor_panel/dashboard | doctor dashboard stats | +| visit_notes | via doctor encounters (create in records flow) | + +### Clinic Admin (13.3) + +| Resource | UI | +|----------|-----| +| clinic_services | CRUD create + list | +| staff_assignments | CRUD create + list | +| clinic_policies | CRUD create + list | +| operating_hours_policies | list (via resources) | + +### Patient Portal (13.4) + +| Endpoint | UI Page | +|----------|---------| +| patient-portal/me | Patient profile | +| patient-portal/appointments | upcoming / past / cancel | +| POST patient-portal/appointments | book wizard | +| patient-portal/documents | lab results, reviews, attachments | +| patient-portal/notification-preferences | notifications toggle | + +### Medical Records (13.5–13.6) + +| Endpoint | UI | +|----------|-----| +| medical-records POST/GET | lookup panel + create | +| clinical-lists + conditions/allergies/medications/immunizations | tabs + add | +| encounters create/update/finalize | encounters panel | +| prescription-orders full pipeline | doctor + pharmacy portals | +| pharmacies | admin + pharmacy dashboard | + +### Delivery (13.7) + +| Endpoint | UI | +|----------|-----| +| delivery-integrations | pharmacy delivery page | +| delivery-job-intents | pharmacy delivery page | + +### Integrations (accounting / CRM / communication) + +Mapped via `external_providers` filtered by `provider_kind` on payments, messages, income pages. + +--- + +## Headers & Auth + +| Header | Source | +|--------|--------| +| `Authorization` | Keycloak JWT via `getValidAccessToken()` | +| `X-Tenant-ID` | `useTenantId()` | +| `X-Patient-ID` | patient portal routes only | + +401 triggers silent refresh via `refreshSession()`. + +--- + +## Validation + +| Check | Result | +|-------|--------| +| All entity screens call `healthcareApi` | ✅ | +| No hardcoded mock arrays in module pages | ✅ | +| BFF route exists | `app/api/healthcare/[...path]/route.ts` | +| Error mapping | `HealthcareApiError` with code + message | + +--- + +## Gaps (backend) + +- Collection list for `medical-records` and `encounters` not in client API — UI uses ID lookup / create flows. +- Dedicated insurance, laboratory, imaging request resources not in v0.13.7 OpenAPI — documented as future endpoints. diff --git a/frontend/docs/healthcare-ui-completion-report.md b/frontend/docs/healthcare-ui-completion-report.md new file mode 100644 index 0000000..2325efd --- /dev/null +++ b/frontend/docs/healthcare-ui-completion-report.md @@ -0,0 +1,92 @@ +# Healthcare UI Completion Report + +**Date:** 2026-07-26 +**Scope:** All existing `/healthcare/*` routes (114 pages) +**Module root:** `frontend/src/modules/healthcare` + +--- + +## Summary + +Healthcare portal pages were upgraded from read-only list views to **enterprise CRUD workspaces** backed by the real `healthcareApi` client. A shared `HealthcareResourceWorkspace` engine powers tables, cards, search, saved filters, create/edit dialogs, workflow actions, audit drawers, and permission gates. + +--- + +## Architecture + +| Layer | Location | Role | +|-------|----------|------| +| Routes | `app/healthcare/**` | Thin re-exports only | +| Business UI | `src/modules/healthcare/pages/*` | Portal-specific composition | +| Entity screens | `src/modules/healthcare/components/entities/*` | Full CRUD + workflows | +| CRUD engine | `src/modules/healthcare/components/crud/HealthcareResourceWorkspace.tsx` | Reusable enterprise table | +| Shared UI | `src/shared/ui`, `src/shared/hooks` | Timeline, calendar, filters | +| API | `src/modules/healthcare/services/healthcare-api.ts` | Real BFF → :8010 | + +--- + +## Portal Completion Matrix + +| Portal | Pages | CRUD / Workflows | Calendar | Audit | Permissions | +|--------|-------|------------------|----------|-------|-------------| +| Admin | 14 | Clinics, doctors, patients, pharmacies, settings, audit | — | ✅ | ✅ catalog | +| Reception | 10 | Appointments, queue, walk-in, check-in/out | ✅ | ✅ | ✅ | +| Doctor | 16 | Schedule, availability, appointments, Rx, records | ✅ | ✅ | ✅ doctor gate | +| Patient | 18 | Book/cancel appointments, docs, notifications, profile | — | ✅ | ✅ patient gate | +| Clinic | 11 | Departments, branches, staff, services, policies | — | ✅ | ✅ | +| Hospital | 9 | Departments, beds, staff, appointments | — | ✅ | ✅ | +| Pharmacy | 7 | Rx workflow (accept→ready→pickup), delivery | — | ✅ | ✅ | +| Public site | 17 | Search, book, profiles (existing + API) | — | — | public | +| Hub / Mobile | 3 | Portal launcher | — | — | — | + +--- + +## Enterprise Features Delivered + +- **CRUD:** create / edit / delete dialogs for foundation entities +- **Workflow actions:** appointment confirm/cancel/no-show; visit start/finish/defer; prescription pharmacy pipeline +- **Search & filters:** debounced search, status filter, saved filter presets (localStorage) +- **Views:** responsive table + card toggle +- **Calendar:** `AppointmentCalendar` for reception & doctor schedules +- **Audit:** entity detail drawer + timeline from `/api/v1/audit` +- **Permissions:** `useHealthcarePermissions` + `PermissionDeniedState`; backend enforces 403 +- **Patient portal:** book wizard, cancel appointment, document upload refs, notification prefs +- **Loading / empty / error states:** unified via shared DS + healthcare page shells +- **RTL / theme:** inherits platform CSS vars, dark mode, `--health-accent*` tokens + +--- + +## Key New Files + +``` +src/shared/hooks/useDebouncedValue.ts +src/shared/hooks/useSavedFilters.ts +src/shared/ui/Timeline.tsx +src/shared/ui/AppointmentCalendar.tsx +src/shared/ui/PermissionDeniedState.tsx +src/modules/healthcare/components/crud/HealthcareResourceWorkspace.tsx +src/modules/healthcare/components/entities/foundation.tsx +src/modules/healthcare/components/entities/workflows.tsx +src/modules/healthcare/components/entities/clinical.tsx +src/modules/healthcare/hooks/useHealthcarePermissions.ts +src/modules/healthcare/constants/permissions.ts +``` + +--- + +## Known Limitations + +1. **Encounters list API** — backend exposes create/update/finalize but no collection list; UI supports create + placeholder list until list endpoint ships. +2. **Medical records list** — no list endpoint; lookup-by-ID panel + create form provided. +3. **Insurance / lab / imaging** — not exposed as dedicated REST resources in healthcare service v0.13.7; patient lab results mapped to `patient-portal/documents` with `document_type` filter. +4. **Full build** — Next.js production build fails on pre-existing `modules/hospitality` TypeScript error (`z` namespace); healthcare bundle compiles successfully. + +--- + +## Self-Audit + +- [x] No placeholder buttons on upgraded portal pages +- [x] No mock data in module business code +- [x] Module boundary respected (`validate:healthcare` PASS) +- [x] 114 routes intact +- [x] Zero circular deps in healthcare module (32 files) diff --git a/frontend/docs/healthcare-validation-report.md b/frontend/docs/healthcare-validation-report.md new file mode 100644 index 0000000..e765b91 --- /dev/null +++ b/frontend/docs/healthcare-validation-report.md @@ -0,0 +1,108 @@ +# Healthcare Validation Report + +**Date:** 2026-07-26 +**Environment:** Windows dev / TorbatYar monorepo frontend + +--- + +## Quality Gates + +| Gate | Command | Result | Notes | +|------|---------|--------|-------| +| TypeScript | `npm run typecheck` | ⚠️ PASS healthcare | 1 pre-existing beauty error unrelated | +| ESLint | `npm run lint` | ✅ PASS | Healthcare hooks rule fixed | +| Healthcare imports | `npm run validate:healthcare-imports` | ✅ PASS | | +| Healthcare routes | `npm run validate:healthcare-routes` | ✅ PASS | 114 pages | +| Circular deps | `npm run validate:healthcare-circular` | ✅ PASS | 32 module files | +| Architecture | `npm run validate:architecture` | ✅ PASS | | +| Production build | `npm run build` | ⚠️ BLOCKED | Hospitality TS error (`z` namespace) — healthcare compiled OK in 5.6min | + +--- + +## CRUD Validation (manual / code review) + +| Entity | Create | Read | Update | Delete | Workflow | +|--------|--------|------|--------|--------|----------| +| clinics | ✅ dialog | ✅ table | ✅ dialog | ✅ confirm | — | +| branches | ✅ | ✅ | ✅ | ✅ | — | +| departments | ✅ | ✅ | ✅ | ✅ | — | +| doctors | ✅ | ✅ | ✅ | ✅ | — | +| patients | ✅ | ✅ | ✅ | ✅ | — | +| appointments | ✅ book | ✅ | — | — | confirm/cancel/no-show | +| visit_sessions | ✅ | ✅ | — | — | start/finish/defer/call | +| prescription_orders | ✅ | ✅ | — | — | accept/prepare/ready/pickup/cancel | +| waiting_list | ✅ | ✅ | — | — | — | +| external_providers | ✅ | ✅ | ✅ | ✅ | — | + +--- + +## Permission Validation + +- `useHealthcarePermissions` grants manage to tenant owners / platform admin +- View routes require authenticated tenant + `healthcare.*.view` keys (UI gate) +- API 403 surfaces `PermissionDeniedState` +- Permissions catalog page lists all backend-defined keys + +--- + +## Responsive Validation + +- `HealthcareResourceWorkspace`: table scroll + card grid (`sm:grid-cols-2 xl:grid-cols-3`) +- `TableToolbar` stacks on mobile (`flex-col sm:flex-row`) +- `AppointmentCalendar`: horizontal scroll (`overflow-x-auto`, min-width 720px) +- Portal shells unchanged — mobile portal route `/healthcare/mobile/[portal]` + +--- + +## Accessibility Validation + +- Dialogs: `role="dialog"`, `aria-modal`, Escape to close +- Drawer: labelled title `aria-labelledby` +- Timeline: semantic `
    ` / `