diff --git a/backend/config.py b/backend/config.py index 7dc79bb..c598d41 100644 --- a/backend/config.py +++ b/backend/config.py @@ -36,6 +36,12 @@ class Settings(BaseSettings): menu_images_bucket: str = "menu-images" supabase_jwt_secret: str = "" + # --- Phase 2: clinical adapter global fallbacks (per-client columns are primary) --- + nookal_api_key: str = "" + halaxy_client_id: str = "" + halaxy_client_secret: str = "" + halaxy_environment: str = "au" # "au" | "eu" + # --- Phase 0: queue / worker / billing-portal infra --- redis_url: str = "redis://localhost:6379/0" worker_role: str = "web" # "web" | "worker" | "scheduler" diff --git a/backend/jobs/appointment_followup.py b/backend/jobs/appointment_followup.py index 3998c34..b2e7ecc 100644 --- a/backend/jobs/appointment_followup.py +++ b/backend/jobs/appointment_followup.py @@ -16,10 +16,18 @@ from services.claude import generate_followup_message from services import cliniko as cliniko_adapter from services import square_appointments as square_adapter -from services.twilio_sms import send_sms +from services import nookal as nookal_adapter +from services import halaxy as halaxy_adapter +from services import hotdoc as hotdoc_adapter +from services import jane as jane_adapter +from services import practicepal as practicepal_adapter logger = logging.getLogger(__name__) +# Sentinel for DB lookup failures — same shape as a service_role .execute() result +# with no data, so ``.data`` is safely falsy. +_EMPTY_RESULT = type("FakeResult", (), {"data": None})() + _STATE_CALENDARS = { "NSW": NewSouthWales, "VIC": Victoria, @@ -31,9 +39,16 @@ "NT": NorthernTerritory, } +# All seven booking adapters. Stub adapters (hotdoc/jane/practicepal) register +# with SUPPORTS_REBOOK=False so the job fails safe instead of 404ing. _BOOKING_ADAPTERS = { "cliniko": cliniko_adapter, "square": square_adapter, + "nookal": nookal_adapter, + "halaxy": halaxy_adapter, + "hotdoc": hotdoc_adapter, + "jane": jane_adapter, + "practicepal": practicepal_adapter, } @@ -52,12 +67,48 @@ def is_au_public_holiday(d: date, state: str = "NSW") -> bool: return cal.is_holiday(d) +async def _upsert_practitioners(db, client_id, booking_system: str, appointments: list[dict]) -> None: + """UPSERT ``practitioners`` rows for each unique practitioner seen (C5). + + Records external_id + name + booking_system so opt-outs and the dashboard have + rows even before a follow-up is ever sent. Best-effort: per-row errors are logged. + """ + if not client_id or not appointments: + return + seen: dict[str, str | None] = {} + for appt in appointments: + pid = appt.get("practitioner_id") + if pid: + seen[str(pid)] = appt.get("practitioner_name") + if not seen: + return + for ext_id, name in seen.items(): + try: + db.table("practitioners").upsert( + { + "client_id": client_id, + "booking_system": booking_system, + "external_id": ext_id, + "name": name, + }, + on_conflict="client_id,booking_system,external_id", + ).execute() + except Exception as e: + logger.error("practitioner upsert failed for %s/%s: %s", booking_system, ext_id, e) + + async def identify_lapsed_patients(client: dict) -> list[dict]: """Return patients who completed treatment 55–65 days ago without a future booking. - Each returned dict contains: ``patient_id``, ``patient_name``, - ``last_treatment``, ``last_appointment_date``, ``phone``, ``email``. - Returns ``[]`` on failure or if no lapsed patients are found. + Reads the **canonical normalised** appointment shape (patient_id, patient_name, + patient_phone, patient_email, treatment_type, appointment_date, status, + practitioner_id, practitioner_name, claim) — NOT Cliniko-specific raw keys. + Returns the canonical dicts for lapsed patients (truthy non-empty + ``get_future_appointments`` ⇒ patient has a future booking ⇒ not lapsed). + + After fetching, UPSERTs ``practitioners`` rows (C5) so opt-outs / dashboard have + records. Returns ``[]`` on failure, for unsupported systems, or when no lapsed + patients are found. """ today = date.today() cutoff_end = today - timedelta(days=55) @@ -67,6 +118,11 @@ async def identify_lapsed_patients(client: dict) -> list[dict]: adapter = get_booking_adapter(booking_system) if adapter is None: return [] + if not getattr(adapter, "SUPPORTS_REBOOK", True): + logger.info( + "booking_system %s does not support rebook — skipping", booking_system + ) + return [] appointments = await adapter.get_appointments( client, @@ -75,10 +131,17 @@ async def identify_lapsed_patients(client: dict) -> list[dict]: status="completed", ) + # Practitioner upsert (C5): record every practitioner seen so opt-outs + the + # dashboard have rows. Best-effort — never blocks the follow-up loop. + try: + await _upsert_practitioners(get_db(), client.get("id"), booking_system, appointments) + except Exception as e: + logger.error("practitioner upsert failed for client %s: %s", client.get("id"), e) + lapsed: list[dict] = [] for appt in appointments: - patient_id = _resolve_patient_id(appt) - if patient_id is None: + patient_id = appt.get("patient_id") + if not patient_id: continue future = await adapter.get_future_appointments( @@ -89,42 +152,62 @@ async def identify_lapsed_patients(client: dict) -> list[dict]: if future: continue - patient_info = appt.get("patient", {}) - lapsed.append( - { - "patient_id": str(patient_id), - "patient_name": patient_info.get("name") - or appt.get("patient_name", "Patient"), - "last_treatment": appt.get("appointment_type", "treatment"), - "last_appointment_date": appt.get("appointment_start", ""), - "phone": patient_info.get("phone", ""), - "email": patient_info.get("email", ""), - } - ) + lapsed.append(appt) return lapsed -def _resolve_patient_id(appt: dict) -> str | None: - patient_info = appt.get("patient") - if isinstance(patient_info, dict): - pid = patient_info.get("id") - if pid is not None: - return str(pid) - raw = appt.get("patient_id") - if raw is not None: - return str(raw) - return None +async def _enqueue_sms(pool, to: str, body: str, state: str) -> tuple[str | None, bool, str | None]: + """Enqueue a durable ``send_sms_task`` via arq (C4 outbound migration). + SMS sends route through the Phase 0 durable Twilio wrapper instead of calling + ``services.twilio_sms.send_sms`` directly. Returns ``(sid, sent, error)``: + ``sid`` is the arq job id (the real Twilio SID is recorded inside + ``send_sms_task``), ``sent`` is True when the enqueue succeeded, and ``error`` + is a failure reason or ``None``. + """ + created_pool = False + if pool is None: + from task_queue import get_arq_pool -async def run_appointment_followup_all_clients() -> None: + try: + pool = await get_arq_pool() + created_pool = True + except Exception as e: + logger.error("arq pool unavailable for SMS enqueue: %s", e) + return None, False, f"enqueue_failed: {e}" + try: + job = await pool.enqueue_job("send_sms_task", to, body, state) + except Exception as e: + logger.error("enqueue_job(send_sms_task) failed: %s", e) + return None, False, f"enqueue_failed: {e}" + finally: + if created_pool: + try: + await pool.close() + except Exception: + pass + # We don't pass _job_id, so arq always generates a fresh UUID and enqueue_job + # returns a Job. getattr covers the defensive None case (same result either way). + return getattr(job, "job_id", None), True, None + + +async def run_appointment_followup_all_clients(arq_pool=None) -> None: # type: ignore[no-untyped-def] """APScheduler job — check all clients for lapsed patients and send follow-ups. Daily 8am AEST. Iterates clients where ``active_jobs`` contains ``"appointment_followup"``. Skips AU public holidays and patients with - ``do_not_contact`` set. Logs results to the ``appointments`` table. - Never crashes the scheduler — each client is wrapped in try/except. + ``do_not_contact`` set (patient- OR practitioner-level). Logs results to the + ``appointments`` table. Never crashes the scheduler — each client is wrapped in + try/except. ``arq_pool`` (the worker's ``ctx["redis"]``) is threaded through so + outbound SMS sends enqueue on the existing pool rather than opening new ones. """ + # --- Skip entire run on AU public holidays (avoids wasting Claude credit) --- + today = date.today() + if is_au_public_holiday(today): + logger.info("Skipping appointment followup — %s is a public holiday", today.isoformat()) + return + db = get_db() resp = db.table("clients").select("*").execute() if not resp.data: @@ -137,6 +220,31 @@ async def run_appointment_followup_all_clients() -> None: if "appointment_followup" not in active_jobs: continue + booking_system = client.get("booking_system", "cliniko") + adapter = get_booking_adapter(booking_system) + if adapter is not None and not getattr(adapter, "SUPPORTS_REBOOK", True): + logger.info( + "Client %s booking_system %s unsupported — skipping (adapter_unsupported)", + client.get("id"), + booking_system, + ) + try: + db.table("appointments").insert( + { + "client_id": client.get("id"), + "patient_id": "adapter_unsupported", + "appointment_date": date.today().isoformat(), + "followup_error": "adapter_unsupported", + } + ).execute() + except Exception as e: + logger.error( + "failed to write adapter_unsupported marker for client %s: %s", + client.get("id"), + e, + ) + continue + try: lapsed = await identify_lapsed_patients(client) except Exception as e: @@ -149,11 +257,11 @@ async def run_appointment_followup_all_clients() -> None: for patient in lapsed: try: - await _process_lapsed_patient(db, client, patient) + await _process_lapsed_patient(db, client, patient, arq_pool) except Exception as e: logger.error( "Follow-up failed for patient %s (client %s): %s", - patient["patient_id"], + patient.get("patient_id"), client.get("id"), e, ) @@ -163,66 +271,126 @@ async def _process_lapsed_patient( db, client: dict, patient: dict, + arq_pool=None, # type: ignore[no-untyped-def] ) -> None: - """Check do-not-contact, generate message, send SMS, log to appointments table.""" + """Check do-not-contact, generate message, send SMS, log to appointments table. + + ``patient`` is a canonical normalised appointment dict. Uses + ``adapter.ID_COLUMN`` for the patient do_not_contact lookup (fixes the + hardcoded cliniko/square id-column dispatch bug) and additionally checks + ``practitioners.do_not_contact`` (whole-practitioner suppression). Threads + ``practitioner_name`` and ``claim_type`` into message generation, routes the SMS + through the durable ``send_sms_task`` (C4), and writes followup/practitioner/ + claim columns to ``appointments`` (columns added in migration 016). + """ booking_system = client.get("booking_system", "cliniko") - id_column = "cliniko_id" if booking_system == "cliniko" else "square_id" + adapter = get_booking_adapter(booking_system) + id_column = getattr(adapter, "ID_COLUMN", "cliniko_id") if adapter else "cliniko_id" + client_id = client.get("id") + patient_id = patient.get("patient_id") + + # --- Patient-level do-not-contact (uses adapter.ID_COLUMN) --- try: patient_db = ( db.table("patients") .select("do_not_contact") - .eq("client_id", client.get("id")) - .eq(id_column, patient["patient_id"]) + .eq("client_id", client_id) + .eq(id_column, patient_id) .maybe_single() .execute() ) except Exception as e: logger.error( "patients lookup failed for %s (client %s): %s", - patient["patient_id"], - client.get("id"), + patient_id, + client_id, e, ) - patient_db = type("FakeResult", (), {"data": None})() + patient_db = _EMPTY_RESULT if patient_db.data and patient_db.data.get("do_not_contact"): logger.info( "Skipping patient %s — do_not_contact is set", - patient["patient_id"], + patient_id, ) return - message = await generate_followup_message( - patient_name=patient["patient_name"], - last_treatment=patient["last_treatment"], - business_name=client.get("business_name", ""), - channel="sms", - ) + # --- Practitioner-level do-not-contact (whole-practitioner suppression) --- + practitioner_id = patient.get("practitioner_id") + if practitioner_id: + try: + prac_db = ( + db.table("practitioners") + .select("do_not_contact") + .eq("client_id", client_id) + .eq("booking_system", booking_system) + .eq("external_id", str(practitioner_id)) + .maybe_single() + .execute() + ) + except Exception as e: + logger.error( + "practitioners lookup failed for %s (client %s): %s", + practitioner_id, + client_id, + e, + ) + prac_db = _EMPTY_RESULT + if prac_db.data and prac_db.data.get("do_not_contact"): + logger.info( + "Skipping patient %s — practitioner %s do_not_contact is set", + patient_id, + practitioner_id, + ) + return - phone = patient.get("phone", "") + # --- Skip early if no phone (avoid wasting Claude credit) --- + phone = patient.get("patient_phone") or "" if not phone: logger.info( "No phone for patient %s — skipping SMS", - patient["patient_id"], + patient_id, ) return - result = await send_sms( - to=phone, - body=message, - state=client.get("state", "NSW"), + # --- Message generation (thread practitioner_name + claim_type) --- + claim = patient.get("claim") or {} + message = await generate_followup_message( + patient_name=patient.get("patient_name", "Patient"), + last_treatment=patient.get("treatment_type", "treatment"), + business_name=client.get("business_name", ""), + channel="sms", + practitioner_name=patient.get("practitioner_name"), + claim_type=claim.get("type"), ) + sid, sent, error = await _enqueue_sms(arq_pool, phone, message, client.get("state", "NSW")) + + # --- Log to appointments (followup/practitioner/claim columns added in 016) --- insert_data = { - "client_id": client.get("id"), - "patient_name": patient["patient_name"], - "followup_sent": result["sent"], + "client_id": client_id, + "patient_id": str(patient_id), + "patient_name": patient.get("patient_name"), + "patient_phone": phone, + "patient_email": patient.get("patient_email"), + "treatment_type": patient.get("treatment_type", ""), + "appointment_date": patient.get("appointment_date") or date.today().isoformat(), + "status": patient.get("status", "completed"), + "practitioner_id": str(practitioner_id) if practitioner_id else None, + "practitioner_name": patient.get("practitioner_name"), + "claim_type": claim.get("type"), + "claim_fund": claim.get("fund"), + "claim_gap_amount": claim.get("gap_amount"), + "followup_sent": sent, "followup_channel": "sms", "followup_message": message, } - if result.get("sid"): - insert_data["followup_sid"] = result["sid"] - if result.get("reason"): - insert_data["followup_error"] = result["reason"] + if sid: + insert_data["followup_sid"] = sid + if error: + insert_data["followup_error"] = error - db.table("appointments").insert(insert_data).execute() + try: + db.table("appointments").insert(insert_data).execute() + except Exception as e: + logger.error("appointments insert failed for patient %s: %s", patient_id, e) diff --git a/backend/main.py b/backend/main.py index 846d4d4..d9c6c84 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ from config import settings from db import init_db from scheduler import create_scheduler -from routers import auth, webhooks, drafts, billing, locations, menu, seo +from routers import auth, webhooks, drafts, billing, locations, menu, seo, practitioners logging.basicConfig(level=logging.INFO) @@ -78,6 +78,7 @@ async def lifespan(app: FastAPI): app.include_router(billing.router, prefix="/billing") app.include_router(locations.router, prefix="/locations") app.include_router(menu.router, prefix="/menu") +app.include_router(practitioners.router, prefix="/practitioners") try: from routers import approve app.include_router(approve.router, prefix="/approve") diff --git a/backend/routers/practitioners.py b/backend/routers/practitioners.py new file mode 100644 index 0000000..c118e97 --- /dev/null +++ b/backend/routers/practitioners.py @@ -0,0 +1,66 @@ +"""Practitioners read + opt-out API (Phase 2 — Clinical, read API for Phase 5). + +``GET /practitioners`` lists a client's practitioners (incl. ``do_not_contact``). +``PATCH /practitioners/{id}`` toggles the per-practitioner opt-out so a clinic can +suppress follow-ups routed to a specific clinician. + +Tenant scoping: this phase runs standalone (the Phase 1 tenant-auth binding is on a +separate branch), so ``client_id`` is accepted as a query/body param and the +standard ``Depends(require_auth)`` guards the endpoint. The practitioner update is +additionally scoped to the supplied ``client_id`` so an id from another tenant +cannot be mutated. +""" +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query + +from db import get_db +from middleware.auth import require_auth + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("") +async def list_practitioners( + client_id: str = Query(..., description="Client whose practitioners to list"), + auth: dict = Depends(require_auth), +): + """List practitioners for a client, including opt-out state.""" + db = get_db() + resp = ( + db.table("practitioners") + .select("*") + .eq("client_id", client_id) + .order_by("name") + .execute() + ) + return {"practitioners": resp.data or []} + + +@router.patch("/{practitioner_id}") +async def update_practitioner( + practitioner_id: str, + payload: dict, + client_id: str = Query(..., description="Client that owns the practitioner"), + auth: dict = Depends(require_auth), +): + """Update a practitioner — currently supports the opt-out toggle. + + Accepts ``{"do_not_contact": bool}``. Scoped to ``client_id`` so a practitioner + id from another tenant cannot be mutated. + """ + if "do_not_contact" not in payload: + raise HTTPException(status_code=422, detail="Missing field: do_not_contact") + + db = get_db() + resp = ( + db.table("practitioners") + .update({"do_not_contact": bool(payload["do_not_contact"])}) + .eq("id", practitioner_id) + .eq("client_id", client_id) + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Practitioner not found") + return {"practitioner": resp.data[0]} diff --git a/backend/services/appointment_shape.py b/backend/services/appointment_shape.py new file mode 100644 index 0000000..dc6de33 --- /dev/null +++ b/backend/services/appointment_shape.py @@ -0,0 +1,91 @@ +"""Canonical normalised appointment shape shared by all booking adapters. + +Every adapter's ``get_appointments`` / ``get_future_appointments`` returns a list of +dicts produced by :func:`canonical_appointment`, so the daily Rebook job reads ONE +agreed shape regardless of PMS. This fixes the normalisation-mismatch bug where the +job read Cliniko-shaped raw keys (``appt["patient"]["id"]``) while Square returned a +flat dict (``patient_id``) — so Square patients came back with name "Patient", no +phone and wrong treatment/date fields. + +Canonical keys (extends the Square flat shape with practitioner + claim): + + patient_id, patient_name, patient_phone, patient_email, treatment_type, + appointment_date, status, practitioner_id, practitioner_name, claim (dict|None) + +``claim`` (best-effort, PMS-sourced — D3-A keeps it OUT of SMS copy): + {"type": "bulk_billed"|"gap"|"private_health"|"unknown", + "fund": str|None, "gap_amount": float|None} +""" +from datetime import datetime + + +def parse_iso_date(value: str | None) -> str: + """Extract YYYY-MM-DD from an ISO date/datetime string, or return ``""``. + + Shared by all adapters that previously carried a private ``_parse_date``. + Nookal's plain ``"YYYY-MM-DD"`` fallback (some PMS APIs return bare dates) + fires only in the except branch, so it is harmless for other adapters. + """ + if not value: + return "" + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date().isoformat() + except (ValueError, TypeError): + if isinstance(value, str) and len(value) >= 10: + return value[:10] + return "" + + +def canonical_appointment( + *, + patient_id, + patient_name, + patient_phone=None, + patient_email=None, + treatment_type="", + appointment_date="", + status="completed", + practitioner_id=None, + practitioner_name=None, + claim=None, +) -> dict: + """Build a normalised appointment dict with exactly the canonical keys.""" + return { + "patient_id": str(patient_id) if patient_id is not None and patient_id != "" else "", + "patient_name": patient_name or "Patient", + "patient_phone": patient_phone, + "patient_email": patient_email, + "treatment_type": treatment_type or "", + "appointment_date": appointment_date or "", + "status": status or "completed", + "practitioner_id": str(practitioner_id) if practitioner_id else None, + "practitioner_name": practitioner_name, + "claim": claim, + } + + +def make_claim(claim_type=None, fund=None, gap_amount=None) -> dict | None: + """Build a best-effort claim dict, or ``None`` when no claim data is available.""" + if not claim_type and not fund and gap_amount is None: + return None + return { + "type": claim_type or "unknown", + "fund": fund, + "gap_amount": gap_amount, + } + + +def extract_claim_from_billing(item: dict) -> dict | None: + """Best-effort claim extraction from a PMS billing/invoice block, else ``None``. + + Shared by adapters (Nookal, Cliniko) that surface a ``billing`` or + ``invoice`` sub-dict on appointment payloads. + """ + billing = item.get("billing") or item.get("invoice") or {} + if not isinstance(billing, dict) or not billing: + return None + return make_claim( + claim_type=billing.get("claim_type") or billing.get("type"), + fund=billing.get("fund") or billing.get("health_fund"), + gap_amount=billing.get("gap_amount"), + ) diff --git a/backend/services/booking_credentials.py b/backend/services/booking_credentials.py new file mode 100644 index 0000000..8cf9be4 --- /dev/null +++ b/backend/services/booking_credentials.py @@ -0,0 +1,39 @@ +"""Shared credential helper for booking-system adapters. + +Centralises credential resolution so every adapter (existing + new) reads its keys +the same way, and we can migrate plaintext booking keys to Fernet-encrypted-at-rest +without touching each adapter. + +``get_credential(client, key)`` returns plaintext. Whether the stored value is +decrypted depends on an explicit key-name mapping (per the plan's D4): keys that +are stored encrypted-at-rest are decrypted via :func:`services.crypto.decrypt`; +plaintext keys (current Cliniko/Square) are returned raw for back-compat. +""" +# Credential columns that are stored Fernet-encrypted at rest. Every other column +# is read as plaintext (back-compat with the current plaintext Cliniko/Square keys). +# Add a key here when its column is migrated to encrypted storage. +_ENCRYPTED_KEYS: set[str] = {"halaxy_client_secret"} + + +def get_credential(client: dict, key: str) -> str: + """Return the plaintext value of ``client[key]``. + + For keys known to be stored encrypted (see :data:`_ENCRYPTED_KEYS`), decrypts via + :func:`services.crypto.decrypt`; if decryption fails (e.g. the value is still + plaintext during dev / pre-backfill, or the encryption key is unset) the raw + value is returned for back-compat (part-clinical: "decrypt when encrypted, else + plaintext"). For plaintext keys the raw value is returned directly. Returns + ``""`` when the key is missing or empty — never raises. + """ + value = client.get(key) if isinstance(client, dict) else None + if not value: + return "" + if key in _ENCRYPTED_KEYS: + try: + from services.crypto import decrypt + return decrypt(value) + except Exception: + # Not a valid Fernet token (plaintext / pre-backfill) or key unset — + # fall back to the raw value (back-compat with current plaintext keys). + return value + return value diff --git a/backend/services/claude.py b/backend/services/claude.py index 8fbc1af..b76921e 100644 --- a/backend/services/claude.py +++ b/backend/services/claude.py @@ -173,9 +173,17 @@ async def generate_followup_message( patient_name: str, last_treatment: str, business_name: str, - channel: str + channel: str, + practitioner_name: str | None = None, + claim_type: str | None = None, ) -> str: - """Generate re-booking follow-up message. Called by Phase 6.""" + """Generate re-booking follow-up message. + + ``practitioner_name`` lets the copy reference the patient's usual clinician + (e.g. "…with Dr Chen") when known. ``claim_type`` (best-effort, PMS-sourced) + tunes the tone without asserting specifics — per D3-A detailed claim/health-fund + info is kept OUT of the SMS body. + """ system_prompt = """You are writing a re-booking message for an Australian healthcare/clinic patient. SMS rules: @@ -187,6 +195,17 @@ async def generate_followup_message( - Max 100 words - Warm, personal +PRACTITIONER: +- When a practitioner name is provided, you may reference them naturally + (e.g. "book your next visit with Dr Chen"). Do not invent a name when none is given. + +CLAIM / BILLING GUARDRAILS: +- Never quote exact dollar amounts, gaps, out-of-pocket costs or rebates unless + they are explicitly provided AND verified. +- Never make medical or financial claims (e.g. "fully covered", "no out-of-pocket", + "bulk-billed at no cost"). Keep any claim/billing context generic at most. +- Do not mention a health fund or Medicare by name unless explicitly provided. + FORBIDDEN PHRASES: - "friendly reminder" - "reach out" @@ -196,13 +215,25 @@ async def generate_followup_message( try: client = _get_client() + parts = [ + f"Patient: {patient_name}", + f"Last treatment: {last_treatment}", + f"Business: {business_name}", + f"Channel: {channel}", + ] + if practitioner_name: + parts.append(f"Practitioner: {practitioner_name}") + if claim_type: + parts.append(f"Claim type: {claim_type}") + parts.append("\nWrite the message:") + user_content = "\n".join(parts) message = client.messages.create( model=MODEL, max_tokens=200, system=system_prompt, messages=[{ "role": "user", - "content": f"Patient: {patient_name}\nLast treatment: {last_treatment}\nBusiness: {business_name}\nChannel: {channel}\n\nWrite the message:" + "content": user_content, }] ) return message.content[0].text.strip() diff --git a/backend/services/cliniko.py b/backend/services/cliniko.py index f9a215f..34b2858 100644 --- a/backend/services/cliniko.py +++ b/backend/services/cliniko.py @@ -1,10 +1,76 @@ +"""Cliniko booking adapter. + +Emits the canonical normalised appointment shape (see +:mod:`services.appointment_shape`) including practitioner + best-effort claim +fields. Cliniko appointments expose a ``practitioner`` link; billing/claim data +lives on invoices rather than the appointments endpoint, so ``claim`` stays +``None`` here unless billing fields are present on the appointment payload. +""" import logging import httpx +from services.appointment_shape import canonical_appointment, extract_claim_from_billing, parse_iso_date +from services.booking_credentials import get_credential + logger = logging.getLogger(__name__) CLINIKO_BASE = "https://api.cliniko.com/v1" +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "cliniko" +ID_COLUMN = "cliniko_id" # patients table column for do_not_contact lookup +CREDENTIAL_KEYS = ["cliniko_api_key"] +SUPPORTS_REBOOK = True +AUTH_MODEL = "api_key" + + +def _practitioner(appt: dict) -> tuple[str | None, str | None]: + """Extract (practitioner_id, practitioner_name) from a Cliniko appointment. + + Cliniko may embed ``practitioner_id`` directly, nest a ``practitioner`` object, + or carry it under ``links``. Handle each defensively. + """ + prac_id = appt.get("practitioner_id") + prac_name = appt.get("practitioner_name") + practitioner = appt.get("practitioner") + if isinstance(practitioner, dict): + prac_id = prac_id or practitioner.get("id") + prac_name = prac_name or practitioner.get("name") + if not prac_id: + for link in appt.get("links", []) or []: + if isinstance(link, dict) and link.get("rel") == "practitioner": + prac_id = prac_id or link.get("id") + break + return (str(prac_id) if prac_id else None, prac_name) + + +def _normalise(appt: dict) -> dict: + """Normalise a raw Cliniko appointment into the canonical shape.""" + patient = appt.get("patient") or {} + if not isinstance(patient, dict): + patient = {} + prac_id, prac_name = _practitioner(appt) + return canonical_appointment( + patient_id=patient.get("id"), + patient_name=patient.get("name"), + patient_phone=patient.get("phone"), + patient_email=patient.get("email"), + treatment_type=appt.get("appointment_type", ""), + appointment_date=parse_iso_date(appt.get("appointment_start")), + status=appt.get("status", "completed"), + practitioner_id=prac_id, + practitioner_name=prac_name, + claim=extract_claim_from_billing(appt), + ) + + +def _headers(client: dict) -> dict: + return { + "Authorization": f"Bearer {get_credential(client, 'cliniko_api_key')}", + "Accept": "application/json", + "User-Agent": "CrewCircle/1.0", + } + async def get_appointments( client: dict, @@ -14,14 +80,9 @@ async def get_appointments( ) -> list[dict]: """Fetch appointments from Cliniko API for a client between dates. - Uses Bearer auth from *per-client* credential store (``client['cliniko_api_key']``). + Uses Bearer auth from the per-client credential store (``cliniko_api_key``). Returns ``[]`` on any failure. """ - headers = { - "Authorization": f"Bearer {client.get('cliniko_api_key', '')}", - "Accept": "application/json", - "User-Agent": "CrewCircle/1.0", - } params = { "appointment_start_from": date_from, "appointment_start_to": date_to, @@ -31,13 +92,13 @@ async def get_appointments( async with httpx.AsyncClient() as session: resp = await session.get( f"{CLINIKO_BASE}/appointments", - headers=headers, + headers=_headers(client), params=params, timeout=30, ) resp.raise_for_status() data = resp.json() - return data.get("appointments", []) + return [_normalise(a) for a in data.get("appointments", [])] except Exception as e: logger.error("Cliniko get_appointments failed: %s", e) return [] @@ -52,11 +113,6 @@ async def get_future_appointments( Returns ``[]`` on failure or if no future appointments exist. """ - headers = { - "Authorization": f"Bearer {client.get('cliniko_api_key', '')}", - "Accept": "application/json", - "User-Agent": "CrewCircle/1.0", - } params = { "patient_id": patient_id, "appointment_start_from": after, @@ -65,13 +121,13 @@ async def get_future_appointments( async with httpx.AsyncClient() as session: resp = await session.get( f"{CLINIKO_BASE}/appointments", - headers=headers, + headers=_headers(client), params=params, timeout=30, ) resp.raise_for_status() data = resp.json() - return data.get("appointments", []) + return [_normalise(a) for a in data.get("appointments", [])] except Exception as e: logger.error("Cliniko get_future_appointments failed: %s", e) return [] diff --git a/backend/services/halaxy.py b/backend/services/halaxy.py new file mode 100644 index 0000000..0178b1c --- /dev/null +++ b/backend/services/halaxy.py @@ -0,0 +1,240 @@ +"""Halaxy booking adapter (full, real — OAuth2 client_credentials + FHIR). + +Auth: OAuth2 ``client_credentials`` → a Bearer token cached ~15 min per client. +Endpoints: FHIR ``GET /Appointment?date=…&status=…`` and +``GET /Appointment?patient={id}&date=ge{after}`` (``Accept: application/fhir+json``). +FHIR ``Appointment.participant`` → ``Practitioner`` reference supplies the +practitioner. Rate-limit aware (500 req/min sliding window): retries once with +backoff on HTTP 429. ``SUPPORTS_REBOOK=True``. + +Halaxy's API is a paid add-on per clinic account; Rebook only works for Halaxy +clients who have enabled it. ``[]`` is returned on any error, including a +token-fetch failure. +""" +import asyncio +import logging +import time + +import httpx + +from config import settings +from services.appointment_shape import canonical_appointment, parse_iso_date +from services.booking_credentials import get_credential + +logger = logging.getLogger(__name__) + +_HALAXY_BASES = { + "au": "https://au-api.halaxy.com/main", + "eu": "https://eu-api.halaxy.com/main", +} + +# Token cache: client_id -> (access_token, expiry_epoch). Tokens last ~15 min; we +# refresh a little early (30s margin) so a request never hits an expired token. +_token_cache: dict[str, tuple[str, float]] = {} +_TOKEN_MARGIN = 30.0 +# Rate-limit backoff: one retry on HTTP 429 before giving up. +_429_BACKOFF = 1.0 + +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "halaxy" +ID_COLUMN = "halaxy_id" # patients table column for do_not_contact lookup +CREDENTIAL_KEYS = ["halaxy_client_id", "halaxy_client_secret"] +SUPPORTS_REBOOK = True +AUTH_MODEL = "oauth2_client_credentials" + + +def _base_url() -> str: + env = getattr(settings, "halaxy_environment", "au").lower() + return _HALAXY_BASES.get(env, _HALAXY_BASES["au"]) + + +def _client_credentials(client: dict) -> tuple[str, str]: + cid = get_credential(client, "halaxy_client_id") or getattr(settings, "halaxy_client_id", "") + secret = get_credential(client, "halaxy_client_secret") or getattr(settings, "halaxy_client_secret", "") + return cid, secret + + +async def _get_token(client: dict) -> str: + """Obtain (and cache) a Halaxy Bearer token via client_credentials. + + Returns ``""`` on any failure (missing credentials or token endpoint error). + """ + cid, secret = _client_credentials(client) + if not cid or not secret: + logger.error("Halaxy: missing client_id/secret for client %s", client.get("id")) + return "" + now = time.time() + cached = _token_cache.get(cid) + if cached and cached[1] > now + _TOKEN_MARGIN: + return cached[0] + try: + async with httpx.AsyncClient() as session: + resp = await session.post( + f"{_base_url()}/token", + data={"grant_type": "client_credentials", "client_id": cid, "client_secret": secret}, + headers={"Accept": "application/json"}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + token = data.get("access_token", "") + expires_in = float(data.get("expires_in", 900)) + _token_cache[cid] = (token, now + expires_in) + return token + except Exception as e: + logger.error("Halaxy token fetch failed: %s", e) + return "" + + +# Map FHIR Appointment.status to our internal status labels. +_FHIR_STATUS_MAP = { + "fulfilled": "completed", + "arrived": "completed", + "cancelled": "cancelled", + "noshow": "noshow", + "booked": "booked", + "proposed": "proposed", +} + + +def _map_status(fhir_status: str | None) -> str: + if not fhir_status: + return "completed" + return _FHIR_STATUS_MAP.get(fhir_status, fhir_status) + + +def _service_type(appt: dict) -> str: + """Extract a treatment-type label from a FHIR Appointment's serviceType.""" + for st in appt.get("serviceType") or []: + if not isinstance(st, dict): + continue + for coding in st.get("coding") or []: + if coding.get("display"): + return coding["display"] + if coding.get("code"): + return coding["code"] + return "" + + +def _participants(appt: dict) -> tuple[str | None, str | None, str | None, str | None]: + """Return (patient_id, patient_name, practitioner_id, practitioner_name). + + FHIR Appointment.participant carries actor references like + ``Patient/{id}`` / ``Practitioner/{id}`` with an optional ``display``. + """ + patient_id = patient_name = practitioner_id = practitioner_name = None + for p in appt.get("participant") or []: + actor = (p or {}).get("actor") or {} + ref = actor.get("reference", "") + display = actor.get("display") + if ref.startswith("Practitioner/"): + practitioner_id = ref.split("/", 1)[1] + practitioner_name = display or practitioner_name + elif ref.startswith("Patient/"): + patient_id = ref.split("/", 1)[1] + patient_name = display or patient_name + return patient_id, patient_name, practitioner_id, practitioner_name + + +def _normalise_fhir(entry: dict) -> dict: + """Normalise a FHIR bundle entry into the canonical appointment shape.""" + appt = entry.get("resource", entry) if isinstance(entry, dict) else {} + patient_id, patient_name, practitioner_id, practitioner_name = _participants(appt) + return canonical_appointment( + patient_id=patient_id, + patient_name=patient_name, + treatment_type=_service_type(appt), + appointment_date=parse_iso_date(appt.get("start")), + status=_map_status(appt.get("status")), + practitioner_id=practitioner_id, + practitioner_name=practitioner_name, + claim=None, # FHIR Claim/ExplanationOfBenefit lookup is a separate best-effort call + ) + + +def _entries_from(data: dict) -> list[dict]: + """Pull the FHIR bundle entries from a search response.""" + if not isinstance(data, dict): + return [] + entries = data.get("entry") + if isinstance(entries, list): + return entries + return [] + + +async def _fhir_get(url: str, token: str, params: dict) -> httpx.Response | None: + """GET a FHIR endpoint with rate-limit (429) backoff. Returns the response or + ``None`` on transport/HTTP error after the single retry.""" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/fhir+json", + "User-Agent": "CrewCircle/1.0", + } + for attempt in (1, 2): + try: + async with httpx.AsyncClient() as session: + resp = await session.get(url, headers=headers, params=params, timeout=30) + if resp.status_code == 429 and attempt == 1: + logger.warning("Halaxy 429 rate limit — backing off and retrying once") + await asyncio.sleep(_429_BACKOFF) + continue + return resp + except Exception as e: + logger.error("Halaxy FHIR GET failed (attempt %d): %s", attempt, e) + return None + # Unreachable — kept as defensive guard. + return None # pragma: no cover + + +async def get_appointments( + client: dict, + date_from: str, + date_to: str, + status: str = "completed", +) -> list[dict]: + """Fetch appointments from Halaxy (FHIR) for a client between dates. + + Maps our ``status="completed"`` to the FHIR ``fulfilled`` status. Returns + ``[]`` on any failure, including a token-fetch failure. + """ + token = await _get_token(client) + if not token: + return [] + fhir_status = "fulfilled" if status == "completed" else status + params = { + "date": [f"ge{date_from}", f"le{date_to}"], + "status": fhir_status, + } + resp = await _fhir_get(f"{_base_url()}/Appointment", token, params) + if resp is None: + return [] + try: + resp.raise_for_status() + return [_normalise_fhir(e) for e in _entries_from(resp.json())] + except Exception as e: + logger.error("Halaxy get_appointments parse failed: %s", e) + return [] + + +async def get_future_appointments( + client: dict, + patient_id: str, + after: str, +) -> list[dict]: + """Check if a patient has any future bookings after a given ISO date. + + Returns ``[]`` on failure or if no future appointments exist. + """ + token = await _get_token(client) + if not token: + return [] + params = {"patient": patient_id, "date": f"ge{after}"} + resp = await _fhir_get(f"{_base_url()}/Appointment", token, params) + if resp is None: + return [] + try: + resp.raise_for_status() + return [_normalise_fhir(e) for e in _entries_from(resp.json())] + except Exception as e: + logger.error("Halaxy get_future_appointments parse failed: %s", e) + return [] diff --git a/backend/services/hotdoc.py b/backend/services/hotdoc.py new file mode 100644 index 0000000..a6738cf --- /dev/null +++ b/backend/services/hotdoc.py @@ -0,0 +1,39 @@ +"""HotDoc booking adapter (fail-safe stub). + +HotDoc has no open/public API — clinics connect HotDoc to their underlying PMS +(Genie, Core Practice, Nookal, Cliniko). This stub registers HotDoc in the adapter +registry with ``SUPPORTS_REBOOK=False`` so the daily Rebook job fails safe (logs +"no open API, connect underlying PMS" and skips) rather than 404ing. It is the seam +to fill once partner access lands. + +Fallback (documented for onboarding): read the underlying PMS the clinic already +runs behind HotDoc (configure that PMS as the booking_system instead), or apply for +HotDoc partner access and replace this stub with a real adapter. +""" +import logging + +logger = logging.getLogger(__name__) + +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "hotdoc" +ID_COLUMN = "hotdoc_id" # patients table column (rarely populated) +CREDENTIAL_KEYS: list[str] = [] +SUPPORTS_REBOOK = False +AUTH_MODEL = "partner_gated" + +_FALLBACK_MSG = ( + "HotDoc has no open API — connect the underlying PMS (Cliniko/Nookal) instead. " + "Skipping appointment follow-up for this client." +) + + +async def get_appointments(client: dict, date_from: str, date_to: str, status: str = "completed") -> list[dict]: + """HotDoc has no usable public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] + + +async def get_future_appointments(client: dict, patient_id: str, after: str) -> list[dict]: + """HotDoc has no usable public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] diff --git a/backend/services/jane.py b/backend/services/jane.py new file mode 100644 index 0000000..78a6ee0 --- /dev/null +++ b/backend/services/jane.py @@ -0,0 +1,38 @@ +"""Jane.app booking adapter (fail-safe stub). + +Jane.app has no public API — integration is via a vetted partner program only, +with no self-serve keys. This stub registers Jane in the adapter registry with +``SUPPORTS_REBOOK=False`` so the daily Rebook job fails safe (logs "partner-gated" +and skips) rather than 404ing. It is the seam to fill once partner access lands. + +Fallback (documented for onboarding): manual/waitlist CSV import of lapsed +patients, or apply for the Jane partner program and replace this stub with a real +adapter. +""" +import logging + +logger = logging.getLogger(__name__) + +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "jane" +ID_COLUMN = "jane_id" # patients table column (rarely populated) +CREDENTIAL_KEYS: list[str] = [] +SUPPORTS_REBOOK = False +AUTH_MODEL = "partner_gated" + +_FALLBACK_MSG = ( + "Jane.app API is partner-gated (no self-serve keys) — skipping appointment " + "follow-up for this client. Apply for the Jane partner program to enable." +) + + +async def get_appointments(client: dict, date_from: str, date_to: str, status: str = "completed") -> list[dict]: + """Jane.app has no usable public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] + + +async def get_future_appointments(client: dict, patient_id: str, after: str) -> list[dict]: + """Jane.app has no usable public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] diff --git a/backend/services/nookal.py b/backend/services/nookal.py new file mode 100644 index 0000000..f1f93af --- /dev/null +++ b/backend/services/nookal.py @@ -0,0 +1,154 @@ +"""Nookal booking adapter (full, real). + +Account API-key auth against ``https://api.nookal.com/production/v2/``. Uses +``getAppointments`` with date/status filters and normalises each item to the +canonical shape (see :mod:`services.appointment_shape`) including practitioner + +best-effort claim fields. ``SUPPORTS_REBOOK=True``. + +Nookal's exact ``getAppointments`` param names / completed-status value / paging +are not fully documented publicly; the normaliser is defensive and returns ``[]`` +on any error. +""" +import logging + +import httpx + +from config import settings +from services.appointment_shape import canonical_appointment, extract_claim_from_billing, parse_iso_date +from services.booking_credentials import get_credential + +logger = logging.getLogger(__name__) +NOOKAL_BASE = "https://api.nookal.com/production/v2" + +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "nookal" +ID_COLUMN = "nookal_id" # patients table column for do_not_contact lookup +CREDENTIAL_KEYS = ["nookal_api_key"] +SUPPORTS_REBOOK = True +AUTH_MODEL = "api_key" + +# Map our internal status to Nookal's appointment status labels. +_STATUS_MAP = { + "completed": "Completed", + "cancelled": "Cancelled", + "noshow": "No Show", +} + + +def _normalise(item: dict) -> dict: + """Normalise a Nookal appointment object into the canonical shape. + + Nookal appointments carry ``practitionerId``/``practitionerName`` directly. The + patient block may be nested under ``patient`` or flattened to ``patientId`` / + ``patientName`` — handle both. + """ + patient = item.get("patient") or {} + if not isinstance(patient, dict): + patient = {} + prac_id = item.get("practitionerId") or item.get("practitioner_id") + return canonical_appointment( + patient_id=item.get("patientId") or item.get("patient_id") or patient.get("id"), + patient_name=item.get("patientName") or item.get("patient_name") or patient.get("name"), + patient_phone=item.get("patientPhone") or item.get("patient_phone") or patient.get("phone"), + patient_email=item.get("patientEmail") or item.get("patient_email") or patient.get("email"), + treatment_type=( + item.get("appointmentType") + or item.get("appointment_type") + or item.get("type") + or item.get("name") + or "" + ), + appointment_date=parse_iso_date( + item.get("date") or item.get("appointmentDate") or item.get("appointment_date") or item.get("start") + ), + status=item.get("status", "completed"), + practitioner_id=prac_id, + practitioner_name=item.get("practitionerName") or item.get("practitioner_name"), + claim=extract_claim_from_billing(item), + ) + + +def _api_key(client: dict) -> str: + return get_credential(client, "nookal_api_key") or getattr(settings, "nookal_api_key", "") + + +def _appointments_from(data: dict) -> list[dict]: + """Pull the appointments list from a Nookal response, handling wrapper shapes.""" + if not isinstance(data, dict): + return [] + for key in ("appointments", "data"): + node = data.get(key) + if isinstance(node, dict) and isinstance(node.get("appointments"), list): + return node["appointments"] + if isinstance(node, list): + return node + return [] + + +async def get_appointments( + client: dict, + date_from: str, + date_to: str, + status: str = "completed", +) -> list[dict]: + """Fetch appointments from Nookal for a client between dates. + + Uses account API-key auth (query param ``api_key``). Returns ``[]`` on any + failure. + """ + api_key = _api_key(client) + if not api_key: + logger.error("Nookal get_appointments: no api_key configured for client %s", client.get("id")) + return [] + params = { + "api_key": api_key, + "from": date_from, + "to": date_to, + "status": _STATUS_MAP.get(status, status), + } + try: + async with httpx.AsyncClient() as session: + resp = await session.get( + f"{NOOKAL_BASE}/getAppointments", + params=params, + headers={"Accept": "application/json", "User-Agent": "CrewCircle/1.0"}, + timeout=30, + ) + resp.raise_for_status() + return [_normalise(a) for a in _appointments_from(resp.json())] + except Exception as e: + logger.error("Nookal get_appointments failed: %s", e) + return [] + + +async def get_future_appointments( + client: dict, + patient_id: str, + after: str, +) -> list[dict]: + """Check if a patient has any future bookings after a given ISO date. + + Returns ``[]`` on failure or if no future appointments exist. + """ + api_key = _api_key(client) + if not api_key: + logger.error("Nookal get_future_appointments: no api_key configured") + return [] + params = { + "api_key": api_key, + "patientId": patient_id, + "from": after, + } + try: + async with httpx.AsyncClient() as session: + resp = await session.get( + f"{NOOKAL_BASE}/getAppointments", + params=params, + headers={"Accept": "application/json", "User-Agent": "CrewCircle/1.0"}, + timeout=30, + ) + resp.raise_for_status() + return [_normalise(a) for a in _appointments_from(resp.json())] + except Exception as e: + logger.error("Nookal get_future_appointments failed: %s", e) + return [] diff --git a/backend/services/practicepal.py b/backend/services/practicepal.py new file mode 100644 index 0000000..f033ca2 --- /dev/null +++ b/backend/services/practicepal.py @@ -0,0 +1,38 @@ +"""PracticePal booking adapter (fail-safe stub). + +PracticePal has no released public API (its privacy policy states the API is "not +yet released"; the product is UK-focused). This stub registers PracticePal in the +adapter registry with ``SUPPORTS_REBOOK=False`` so the daily Rebook job fails safe +(logs "API not yet released" and skips) rather than 404ing. It is the seam to fill +if/when an API ships. + +Fallback (documented for onboarding): manual/CSV import of lapsed patients; revisit +when PracticePal releases an API. +""" +import logging + +logger = logging.getLogger(__name__) + +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "practicepal" +ID_COLUMN = "practicepal_id" # patients table column (rarely populated) +CREDENTIAL_KEYS: list[str] = [] +SUPPORTS_REBOOK = False +AUTH_MODEL = "none" + +_FALLBACK_MSG = ( + "PracticePal API is not yet released — skipping appointment follow-up for this " + "client. Revisit when an API becomes available." +) + + +async def get_appointments(client: dict, date_from: str, date_to: str, status: str = "completed") -> list[dict]: + """PracticePal has no released public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] + + +async def get_future_appointments(client: dict, patient_id: str, after: str) -> list[dict]: + """PracticePal has no released public API. Logs the fallback and returns ``[]``.""" + logger.warning(_FALLBACK_MSG) + return [] diff --git a/backend/services/square_appointments.py b/backend/services/square_appointments.py index 0cfde66..d734d0c 100644 --- a/backend/services/square_appointments.py +++ b/backend/services/square_appointments.py @@ -1,15 +1,18 @@ """Square Appointments adapter — mirrors services/cliniko.py interface. Uses raw httpx REST calls against the Square Bookings API. -No Square SDK dependency. +No Square SDK dependency. Emits the canonical normalised appointment shape (see +:mod:`services.appointment_shape`) including practitioner (team_member_id) and a +``claim`` of ``None`` (Square does not surface health-fund/Medicare billing). """ import logging -from datetime import datetime import httpx from config import settings +from services.appointment_shape import canonical_appointment, parse_iso_date +from services.booking_credentials import get_credential logger = logging.getLogger(__name__) @@ -25,10 +28,19 @@ "User-Agent": "CrewCircle/1.0", } +# --- Adapter capability metadata (read by jobs/appointment_followup.py) --- +ADAPTER_NAME = "square" +ID_COLUMN = "square_id" # patients table column for do_not_contact lookup +CREDENTIAL_KEYS = ["square_access_token"] +SUPPORTS_REBOOK = True +AUTH_MODEL = "oauth2" + def _get_token(client: dict) -> str: """Resolve Square access token from client record or global settings.""" - return client.get("square_access_token") or getattr(settings, "square_access_token", "") + return get_credential(client, "square_access_token") or getattr( + settings, "square_access_token", "" + ) def _auth_headers(client: dict) -> dict: @@ -36,30 +48,31 @@ def _auth_headers(client: dict) -> dict: return {**_SQUARE_HEADERS, "Authorization": f"Bearer {_get_token(client)}"} -def _parse_date(iso_str: str | None) -> str: - """Extract YYYY-MM-DD from an ISO datetime string, or return empty string.""" - if not iso_str: - return "" - try: - return datetime.fromisoformat(iso_str.replace("Z", "+00:00")).date().isoformat() - except (ValueError, TypeError): - return "" - - def _normalise_booking(raw: dict) -> dict: - """Normalise a Square booking object into our internal appointment dict.""" - segments = raw.get("appointment_segments", []) + """Normalise a Square booking object into the canonical appointment dict. + + Practitioner id comes from the first appointment segment's ``team_member_id``. + Square bookings do not embed the team member's display name (that needs a + separate TeamMembers lookup), so ``practitioner_name`` is left ``None`` here — + a future enhancement can resolve names via the Team API. Square does not surface + health-fund/Medicare claim data, so ``claim`` is ``None``. + """ + segments = raw.get("appointment_segments", []) or [] first_segment = segments[0] if segments else {} start_at = raw.get("start_at") or first_segment.get("start_at") - return { - "patient_id": raw.get("customer_id", ""), - "patient_name": raw.get("customer_id", "Patient"), - "patient_phone": None, - "patient_email": None, - "treatment_type": first_segment.get("service_variation_name", ""), - "appointment_date": _parse_date(start_at), - "status": raw.get("status", "completed"), - } + team_member_id = first_segment.get("team_member_id") + return canonical_appointment( + patient_id=raw.get("customer_id", ""), + patient_name=raw.get("customer_id", "Patient"), + patient_phone=None, + patient_email=None, + treatment_type=first_segment.get("service_variation_name", ""), + appointment_date=parse_iso_date(start_at), + status=raw.get("status", "completed"), + practitioner_id=team_member_id, + practitioner_name=None, + claim=None, + ) async def get_appointments( @@ -119,7 +132,7 @@ async def get_future_appointments( ) resp.raise_for_status() data = resp.json() - return data.get("bookings", []) + return [_normalise_booking(b) for b in data.get("bookings", [])] except Exception as e: logger.error("Square get_future_appointments failed: %s", e) return [] diff --git a/backend/task_queue.py b/backend/task_queue.py index ba2024b..bd3e798 100644 --- a/backend/task_queue.py +++ b/backend/task_queue.py @@ -518,7 +518,9 @@ async def run_competitor_weekly(ctx: dict) -> None: async def run_appointment_daily(ctx: dict) -> None: from jobs.appointment_followup import run_appointment_followup_all_clients - await run_appointment_followup_all_clients() + # Thread the worker's arq pool so outbound SMS sends enqueue on the existing + # pool (C4) rather than opening a new connection per send. + await run_appointment_followup_all_clients(ctx.get("redis")) async def run_trial_hourly(ctx: dict) -> None: diff --git a/backend/tests/unit/test_booking_credentials.py b/backend/tests/unit/test_booking_credentials.py new file mode 100644 index 0000000..304df63 --- /dev/null +++ b/backend/tests/unit/test_booking_credentials.py @@ -0,0 +1,39 @@ +"""Tests for the shared booking-credential helper.""" +from unittest.mock import patch + +from services.booking_credentials import get_credential + + +def test_get_credential_plaintext_returned_raw(): + """cliniko_api_key is a plaintext column — returned as-is (no decrypt).""" + client = {"cliniko_api_key": "raw-cliniko-key"} + assert get_credential(client, "cliniko_api_key") == "raw-cliniko-key" + + +def test_get_credential_decrypts_encrypted_key(): + """halaxy_client_secret is stored Fernet-encrypted — decrypted via crypto.decrypt.""" + client = {"halaxy_client_secret": "gAAAAAB-encrypted-token"} + with patch("services.crypto.decrypt", return_value="decrypted-secret") as mock_decrypt: + result = get_credential(client, "halaxy_client_secret") + assert result == "decrypted-secret" + mock_decrypt.assert_called_once_with("gAAAAAB-encrypted-token") + + +def test_get_credential_missing_key_returns_empty(): + assert get_credential({}, "nookal_api_key") == "" + + +def test_get_credential_empty_value_returns_empty(): + assert get_credential({"cliniko_api_key": ""}, "cliniko_api_key") == "" + + +def test_get_credential_none_client_returns_empty(): + assert get_credential(None, "cliniko_api_key") == "" + + +def test_get_credential_decrypt_failure_falls_back_to_raw(): + """A decrypt failure (plaintext / pre-backfill value, or unset key) falls back to + the raw value for back-compat rather than raising or returning empty.""" + client = {"halaxy_client_secret": "plaintext-secret"} + with patch("services.crypto.decrypt", side_effect=Exception("InvalidToken")): + assert get_credential(client, "halaxy_client_secret") == "plaintext-secret" diff --git a/backend/tests/unit/test_claude.py b/backend/tests/unit/test_claude.py new file mode 100644 index 0000000..5949aa9 --- /dev/null +++ b/backend/tests/unit/test_claude.py @@ -0,0 +1,64 @@ +"""Tests for generate_followup_message practitioner/claim params + guardrails.""" +import pytest +from unittest.mock import patch, MagicMock + +from services import claude + + +def _mock_anthropic(reply="Hi from clinic"): + mock_msg = MagicMock() + mock_msg.content = [MagicMock(text=reply)] + mock_client = MagicMock() + mock_client.messages.create.return_value = mock_msg + return mock_client + + +@pytest.mark.asyncio +async def test_practitioner_and_claim_included_in_user_content(): + mock_client = _mock_anthropic() + with patch.object(claude, "_get_client", return_value=mock_client): + await claude.generate_followup_message( + patient_name="John", + last_treatment="Clean", + business_name="Sydney Dental", + channel="sms", + practitioner_name="Dr Chen", + claim_type="gap", + ) + user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"] + assert "Practitioner: Dr Chen" in user_content + assert "Claim type: gap" in user_content + + +@pytest.mark.asyncio +async def test_practitioner_and_claim_omitted_when_not_provided(): + """Backward compatible: no practitioner/claim lines when params are absent.""" + mock_client = _mock_anthropic() + with patch.object(claude, "_get_client", return_value=mock_client): + await claude.generate_followup_message( + patient_name="John", last_treatment="Clean", business_name="Sydney Dental", channel="sms" + ) + user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"] + assert "Practitioner:" not in user_content + assert "Claim type:" not in user_content + + +@pytest.mark.asyncio +async def test_system_prompt_forbids_dollar_amounts_and_financial_claims(): + mock_client = _mock_anthropic() + with patch.object(claude, "_get_client", return_value=mock_client): + await claude.generate_followup_message( + patient_name="John", last_treatment="Clean", business_name="Clinic", channel="sms" + ) + system_prompt = mock_client.messages.create.call_args.kwargs["system"] + assert "dollar" in system_prompt.lower() + assert "medical or financial claims" in system_prompt.lower() + + +@pytest.mark.asyncio +async def test_returns_generated_text(): + with patch.object(claude, "_get_client", return_value=_mock_anthropic("Book again soon")): + result = await claude.generate_followup_message( + patient_name="John", last_treatment="Clean", business_name="Clinic", channel="sms" + ) + assert result == "Book again soon" diff --git a/backend/tests/unit/test_halaxy_adapter.py b/backend/tests/unit/test_halaxy_adapter.py new file mode 100644 index 0000000..f63fa9d --- /dev/null +++ b/backend/tests/unit/test_halaxy_adapter.py @@ -0,0 +1,162 @@ +"""Tests for the Halaxy booking adapter (OAuth2 + FHIR, mocked httpx).""" +import pytest +from unittest.mock import patch + +import httpx + +from services import halaxy + + +class _FakeResp: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError( + f"{self.status_code}", request=httpx.Request("POST", "x"), response=httpx.Response(self.status_code) + ) + + +class _HalaxyFakeClient: + """One fake client handling the token POST and the FHIR GET (separate AsyncClient + calls in the adapter share this via the patch).""" + + def __init__(self, token_resp, fhir_resp): + self._token = token_resp + self._fhir = fhir_resp + self.post_calls = [] + self.get_calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, **kw): + self.post_calls.append((url, kw)) + if isinstance(self._token, Exception): + raise self._token + return self._token + + async def get(self, url, **kw): + self.get_calls.append((url, kw)) + if isinstance(self._fhir, Exception): + raise self._fhir + return self._fhir + + +_TOKEN_PAYLOAD = {"access_token": "tok-abc", "expires_in": 900} + +_FHIR_BUNDLE = { + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "Appointment", + "id": "appt-1", + "status": "fulfilled", + "start": "2026-05-20T09:00:00+10:00", + "serviceType": [{"coding": [{"display": "General Consult"}]}], + "participant": [ + {"actor": {"reference": "Patient/p-200", "display": "Bob Smith"}}, + {"actor": {"reference": "Practitioner/prac-9", "display": "Dr Nguyen"}}, + ], + } + } + ], +} + + +@pytest.fixture(autouse=True) +def _clear_token_cache(): + halaxy._token_cache.clear() + yield + halaxy._token_cache.clear() + + +def test_halaxy_metadata(): + assert halaxy.ADAPTER_NAME == "halaxy" + assert halaxy.ID_COLUMN == "halaxy_id" + assert halaxy.CREDENTIAL_KEYS == ["halaxy_client_id", "halaxy_client_secret"] + assert halaxy.SUPPORTS_REBOOK is True + assert halaxy.AUTH_MODEL == "oauth2_client_credentials" + + +@pytest.mark.asyncio +async def test_get_appointments_fhir_to_canonical(): + client = {"id": "c1", "halaxy_client_id": "cid", "halaxy_client_secret": "sec"} + fake = _HalaxyFakeClient(_FakeResp(_TOKEN_PAYLOAD), _FakeResp(_FHIR_BUNDLE)) + with patch("services.halaxy.httpx.AsyncClient", return_value=fake): + result = await halaxy.get_appointments(client, "2026-05-01", "2026-05-31", "completed") + + assert len(result) == 1 + appt = result[0] + assert appt["patient_id"] == "p-200" + assert appt["patient_name"] == "Bob Smith" + assert appt["practitioner_id"] == "prac-9" + assert appt["practitioner_name"] == "Dr Nguyen" + assert appt["treatment_type"] == "General Consult" + assert appt["appointment_date"] == "2026-05-20" + assert appt["status"] == "completed" # FHIR "fulfilled" -> "completed" + assert appt["claim"] is None + + # FHIR GET sent with application/fhir+json + Bearer token. + assert len(fake.get_calls) == 1 + url, kw = fake.get_calls[0] + assert url.endswith("/Appointment") + assert kw["headers"]["Accept"] == "application/fhir+json" + assert kw["headers"]["Authorization"] == "Bearer tok-abc" + # date range expressed as ge/le, completed mapped to fulfilled. + assert kw["params"]["date"] == ["ge2026-05-01", "le2026-05-31"] + assert kw["params"]["status"] == "fulfilled" + + +@pytest.mark.asyncio +async def test_token_fetch_failure_returns_empty(): + client = {"id": "c1", "halaxy_client_id": "cid", "halaxy_client_secret": "sec"} + fake = _HalaxyFakeClient(httpx.ConnectError("no token"), _FakeResp(_FHIR_BUNDLE)) + with patch("services.halaxy.httpx.AsyncClient", return_value=fake): + result = await halaxy.get_appointments(client, "2026-05-01", "2026-05-31") + assert result == [] + # No FHIR GET attempted when the token could not be obtained. + assert fake.get_calls == [] + + +@pytest.mark.asyncio +async def test_missing_credentials_returns_empty(): + client = {"id": "c1"} # no client_id/secret, no global fallback + with patch("services.halaxy.httpx.AsyncClient") as mock_client: + result = await halaxy.get_appointments(client, "2026-05-01", "2026-05-31") + assert result == [] + mock_client.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_future_appointments_queries_by_patient(): + client = {"id": "c1", "halaxy_client_id": "cid", "halaxy_client_secret": "sec"} + fake = _HalaxyFakeClient(_FakeResp(_TOKEN_PAYLOAD), _FakeResp(_FHIR_BUNDLE)) + with patch("services.halaxy.httpx.AsyncClient", return_value=fake): + result = await halaxy.get_future_appointments(client, "p-200", "2026-07-22") + assert len(result) == 1 + url, kw = fake.get_calls[0] + assert kw["params"]["patient"] == "p-200" + assert kw["params"]["date"] == "ge2026-07-22" + + +@pytest.mark.asyncio +async def test_token_cached_across_calls(): + """The Bearer token is cached so a second call does not re-POST to the token endpoint.""" + client = {"id": "c1", "halaxy_client_id": "cid", "halaxy_client_secret": "sec"} + fake = _HalaxyFakeClient(_FakeResp(_TOKEN_PAYLOAD), _FakeResp(_FHIR_BUNDLE)) + with patch("services.halaxy.httpx.AsyncClient", return_value=fake): + await halaxy.get_appointments(client, "2026-05-01", "2026-05-31") + await halaxy.get_future_appointments(client, "p-200", "2026-07-22") + # Only one token POST across both calls. + assert len(fake.post_calls) == 1 + assert len(fake.get_calls) == 2 diff --git a/backend/tests/unit/test_nookal_adapter.py b/backend/tests/unit/test_nookal_adapter.py new file mode 100644 index 0000000..864774e --- /dev/null +++ b/backend/tests/unit/test_nookal_adapter.py @@ -0,0 +1,142 @@ +"""Tests for the Nookal booking adapter (mocked httpx — no live API calls).""" +import pytest +from unittest.mock import patch + +import httpx + +from services import nookal + + +class _FakeResp: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError( + f"{self.status_code}", request=httpx.Request("GET", "x"), response=httpx.Response(self.status_code) + ) + + +class _FakeClient: + """Minimal async context-manager httpx stand-in returning canned GET responses.""" + + def __init__(self, responses): + # responses: a single _FakeResp, a list (sequential), or an Exception to raise. + self._responses = responses if isinstance(responses, list) else [responses] + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, **kw): + self.calls.append((url, kw)) + nxt = self._responses.pop(0) + if isinstance(nxt, Exception): + raise nxt + return nxt + + +_NOOKAL_PAYLOAD = { + "data": { + "appointments": [ + { + "patientId": "p-100", + "patientName": "Alice Wong", + "patientPhone": "+61400000001", + "patientEmail": "alice@example.com", + "appointmentType": "Physio Consult", + "date": "2026-05-20T09:00:00+10:00", + "status": "Completed", + "practitionerId": "prac-7", + "practitionerName": "Dr Patel", + "billing": {"type": "gap", "fund": "HCF", "gap_amount": 40.0}, + } + ] + } +} + + +def test_nookal_metadata(): + assert nookal.ADAPTER_NAME == "nookal" + assert nookal.ID_COLUMN == "nookal_id" + assert nookal.CREDENTIAL_KEYS == ["nookal_api_key"] + assert nookal.SUPPORTS_REBOOK is True + assert nookal.AUTH_MODEL == "api_key" + + +@pytest.mark.asyncio +async def test_get_appointments_maps_to_canonical(): + client = {"id": "c1", "nookal_api_key": "nk-key"} + fake = _FakeClient(_FakeResp(_NOOKAL_PAYLOAD)) + with patch("services.nookal.httpx.AsyncClient", return_value=fake): + result = await nookal.get_appointments(client, "2026-05-01", "2026-05-31", "completed") + + assert len(result) == 1 + appt = result[0] + assert appt["patient_id"] == "p-100" + assert appt["patient_name"] == "Alice Wong" + assert appt["patient_phone"] == "+61400000001" + assert appt["treatment_type"] == "Physio Consult" + assert appt["appointment_date"] == "2026-05-20" + assert appt["practitioner_id"] == "prac-7" + assert appt["practitioner_name"] == "Dr Patel" + assert appt["claim"] == {"type": "gap", "fund": "HCF", "gap_amount": 40.0} + + # api_key sent as a query param on the getAppointments endpoint. + url, kw = fake.calls[0] + assert url.endswith("/getAppointments") + assert kw["params"]["api_key"] == "nk-key" + assert kw["params"]["from"] == "2026-05-01" + assert kw["params"]["to"] == "2026-05-31" + + +@pytest.mark.asyncio +async def test_get_appointments_http_error_returns_empty(): + client = {"id": "c1", "nookal_api_key": "nk-key"} + fake = _FakeClient(httpx.ConnectError("boom")) + with patch("services.nookal.httpx.AsyncClient", return_value=fake): + result = await nookal.get_appointments(client, "2026-05-01", "2026-05-31") + assert result == [] + + +@pytest.mark.asyncio +async def test_get_appointments_no_api_key_returns_empty(): + # No per-client key and no global fallback. + client = {"id": "c1"} + with patch("services.nookal.httpx.AsyncClient") as mock_client: + result = await nookal.get_appointments(client, "2026-05-01", "2026-05-31") + assert result == [] + mock_client.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_future_appointments_returns_items(): + client = {"id": "c1", "nookal_api_key": "nk-key"} + fake = _FakeClient(_FakeResp(_NOOKAL_PAYLOAD)) + with patch("services.nookal.httpx.AsyncClient", return_value=fake): + result = await nookal.get_future_appointments(client, "p-100", "2026-07-22") + assert len(result) == 1 + assert result[0]["patient_id"] == "p-100" + url, kw = fake.calls[0] + assert kw["params"]["patientId"] == "p-100" + assert kw["params"]["from"] == "2026-07-22" + + +@pytest.mark.asyncio +async def test_get_appointments_claim_none_when_no_billing(): + client = {"id": "c1", "nookal_api_key": "nk-key"} + payload = {"data": {"appointments": [ + {k: v for k, v in _NOOKAL_PAYLOAD["data"]["appointments"][0].items() if k != "billing"} + ]}} + fake = _FakeClient(_FakeResp(payload)) + with patch("services.nookal.httpx.AsyncClient", return_value=fake): + result = await nookal.get_appointments(client, "2026-05-01", "2026-05-31") + assert result[0]["claim"] is None diff --git a/backend/tests/unit/test_practitioners_api.py b/backend/tests/unit/test_practitioners_api.py new file mode 100644 index 0000000..91156a3 --- /dev/null +++ b/backend/tests/unit/test_practitioners_api.py @@ -0,0 +1,84 @@ +"""Tests for the practitioners read + opt-out API (Phase 2).""" +import pytest +from unittest.mock import patch, MagicMock + +from fastapi import HTTPException + + +def _chain(data): + c = MagicMock() + c.select.return_value = c + c.eq.return_value = c + c.order_by.return_value = c + c.update.return_value = c + c.execute.return_value = MagicMock(data=data) + return c + + +@pytest.mark.asyncio +async def test_list_practitioners_returns_client_scoped_list(): + from routers import practitioners + + rows = [{"id": "p1", "name": "Dr X", "do_not_contact": False}] + db = MagicMock() + db.table.return_value = _chain(rows) + with patch("routers.practitioners.get_db", return_value=db): + result = await practitioners.list_practitioners(client_id="cid", auth={"sub": "anon"}) + + assert result == {"practitioners": rows} + # Query is scoped to the client. + db.table("practitioners").eq.assert_called_with("client_id", "cid") + + +@pytest.mark.asyncio +async def test_list_practitioners_empty(): + from routers import practitioners + + db = MagicMock() + db.table.return_value = _chain([]) + with patch("routers.practitioners.get_db", return_value=db): + result = await practitioners.list_practitioners(client_id="cid", auth={"sub": "anon"}) + assert result == {"practitioners": []} + + +@pytest.mark.asyncio +async def test_update_practitioner_opt_out(): + from routers import practitioners + + db = MagicMock() + chain = _chain([{"id": "p1", "do_not_contact": True}]) + db.table.return_value = chain + with patch("routers.practitioners.get_db", return_value=db): + result = await practitioners.update_practitioner( + "p1", {"do_not_contact": True}, client_id="cid", auth={"sub": "anon"} + ) + + assert result["practitioner"]["do_not_contact"] is True + chain.update.assert_called_with({"do_not_contact": True}) + # Update is scoped by both id AND client_id (cross-tenant protection). + eq_calls = [c.args for c in chain.eq.call_args_list] + assert ("id", "p1") in eq_calls + assert ("client_id", "cid") in eq_calls + + +@pytest.mark.asyncio +async def test_update_missing_do_not_contact_returns_422(): + from routers import practitioners + + with pytest.raises(HTTPException) as exc: + await practitioners.update_practitioner("p1", {}, client_id="cid", auth={"sub": "anon"}) + assert exc.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_update_not_found_returns_404(): + from routers import practitioners + + db = MagicMock() + db.table.return_value = _chain([]) # update matched no row + with patch("routers.practitioners.get_db", return_value=db): + with pytest.raises(HTTPException) as exc: + await practitioners.update_practitioner( + "p1", {"do_not_contact": False}, client_id="cid", auth={"sub": "anon"} + ) + assert exc.value.status_code == 404 diff --git a/backend/tests/unit/test_rebook.py b/backend/tests/unit/test_rebook.py index 0838017..c40b251 100644 --- a/backend/tests/unit/test_rebook.py +++ b/backend/tests/unit/test_rebook.py @@ -1,8 +1,11 @@ -"""Tests for lapsed patient identification and AU holiday gate.""" +"""Tests for lapsed patient identification, canonical-shape handling, multi- +practitioner do-not-contact, ID_COLUMN dispatch, claim threading, practitioner +upsert and the durable SMS enqueue path.""" import sys import types + import pytest -from unittest.mock import patch, AsyncMock +from unittest.mock import patch, AsyncMock, MagicMock from datetime import date @@ -32,9 +35,69 @@ def _install_workalendar_au_stub(): _install_workalendar_au_stub() +def _canonical_appt(**overrides): + """A canonical normalised appointment dict (the Square flat shape extended).""" + appt = { + "patient_id": "pat-001", + "patient_name": "John Smith", + "patient_phone": "+61412345678", + "patient_email": "john@example.com.au", + "treatment_type": "Dental Clean", + "appointment_date": "2026-05-15", + "status": "completed", + "practitioner_id": "prac-01", + "practitioner_name": "Dr Chen", + "claim": {"type": "gap", "fund": "Bupa", "gap_amount": 35.0}, + } + appt.update(overrides) + return appt + + +def _canonical_appointment_future(): + return _canonical_appt(appointment_date="2099-01-01", status="booked") + + +def _chain_with(data): + """A mock supabase query chain whose .execute() returns ``data``.""" + c = MagicMock() + c.select.return_value = c + c.eq.return_value = c + c.maybe_single.return_value = c + c.execute.return_value = MagicMock(data=data) + return c + + +def _mock_db(patient_dnc=None, practitioner_dnc=None): + """Mock DB whose patients/practitioners lookups return the given do_not_contact.""" + db = MagicMock() + patient_data = {"do_not_contact": patient_dnc} if patient_dnc else None + prac_data = {"do_not_contact": practitioner_dnc} if practitioner_dnc else None + insert_chain = MagicMock() + insert_chain.execute.return_value = MagicMock(data=[{"id": "appt-1"}]) + + # Cache one chain per table so test assertions inspect the same object the job used. + patients_chain = _chain_with(patient_data) + prac_chain = _chain_with(prac_data) + + def table(name): + if name == "patients": + return patients_chain + if name == "practitioners": + return prac_chain + if name == "appointments": + return insert_chain + return MagicMock() + + db.table.side_effect = table + db._patients_chain = patients_chain + db._prac_chain = prac_chain + return db + + @pytest.mark.asyncio async def test_lapsed_patient_identified(): - """Patients who completed treatment 55-65 days ago without future booking are identified.""" + """Patients who completed treatment 55-65 days ago without future booking are identified + using the canonical flat shape (regression for the normalisation-mismatch bug).""" from jobs.appointment_followup import identify_lapsed_patients client = { @@ -44,27 +107,231 @@ async def test_lapsed_patient_identified(): "cliniko_api_key": "test-key", } - completed_appointments = [ - { - "patient": { - "id": "pat-001", - "name": "John Smith", - "phone": "+61412345678", - "email": "john@example.com.au", - }, - "appointment_type": "Dental Clean", - "appointment_start": "2026-05-15T10:00:00+10:00", - } - ] - - with patch("services.cliniko.get_appointments", new_callable=AsyncMock, return_value=completed_appointments), \ - patch("services.cliniko.get_future_appointments", new_callable=AsyncMock, return_value=[]): + with patch("services.cliniko.get_appointments", new_callable=AsyncMock, + return_value=[_canonical_appt()]), \ + patch("services.cliniko.get_future_appointments", new_callable=AsyncMock, + return_value=[]), \ + patch("jobs.appointment_followup._upsert_practitioners", new_callable=AsyncMock): lapsed = await identify_lapsed_patients(client) assert len(lapsed) == 1 assert lapsed[0]["patient_id"] == "pat-001" assert lapsed[0]["patient_name"] == "John Smith" - assert lapsed[0]["phone"] == "+61412345678" + # Canonical key is patient_phone (not the old Cliniko "phone") + assert lapsed[0]["patient_phone"] == "+61412345678" + assert lapsed[0]["practitioner_name"] == "Dr Chen" + + +@pytest.mark.asyncio +async def test_square_canonical_shape_read(): + """The job reads the canonical flat shape for a Square client — proving the + normalisation-mismatch bug (Square patients came back as name "Patient", no phone) + is fixed.""" + from jobs.appointment_followup import identify_lapsed_patients + + client = {"id": "c2", "booking_system": "square", "square_access_token": "tok"} + square_appt = _canonical_appt( + patient_id="cust-9", patient_name="Jane Roe", patient_phone="+61411112222" + ) + with patch("services.square_appointments.get_appointments", new_callable=AsyncMock, + return_value=[square_appt]), \ + patch("services.square_appointments.get_future_appointments", new_callable=AsyncMock, + return_value=[]), \ + patch("jobs.appointment_followup._upsert_practitioners", new_callable=AsyncMock): + lapsed = await identify_lapsed_patients(client) + + assert len(lapsed) == 1 + assert lapsed[0]["patient_id"] == "cust-9" + assert lapsed[0]["patient_name"] == "Jane Roe" + assert lapsed[0]["patient_phone"] == "+61411112222" + + +@pytest.mark.asyncio +async def test_unsupported_adapter_returns_empty(): + """A SUPPORTS_REBOOK=False booking system (stub) yields no lapsed patients.""" + from jobs.appointment_followup import identify_lapsed_patients + + client = {"id": "c3", "booking_system": "hotdoc"} + with patch("services.hotdoc.get_appointments", new_callable=AsyncMock) as mock_get: + lapsed = await identify_lapsed_patients(client) + assert lapsed == [] + # The stub is never even called because SUPPORTS_REBOOK short-circuits. + mock_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_patient_with_future_booking_not_lapsed(): + """A patient with a future booking is not lapsed.""" + from jobs.appointment_followup import identify_lapsed_patients + + client = {"id": "c4", "booking_system": "cliniko", "cliniko_api_key": "k"} + with patch("services.cliniko.get_appointments", new_callable=AsyncMock, + return_value=[_canonical_appt()]), \ + patch("services.cliniko.get_future_appointments", new_callable=AsyncMock, + return_value=[_canonical_appointment_future()]), \ + patch("jobs.appointment_followup._upsert_practitioners", new_callable=AsyncMock): + lapsed = await identify_lapsed_patients(client) + assert lapsed == [] + + +@pytest.mark.asyncio +async def test_id_column_dispatch_uses_adapter_id_column(): + """A nookal client uses nookal_id (adapter.ID_COLUMN) for the patients lookup, + not the hardcoded cliniko/square id column.""" + from jobs.appointment_followup import _process_lapsed_patient + from services import nookal as nookal_adapter + + assert nookal_adapter.ID_COLUMN == "nookal_id" + + client = {"id": "cid", "booking_system": "nookal", "business_name": "Clinic", "state": "NSW"} + db = _mock_db(patient_dnc=False) + pool = MagicMock() + pool.enqueue_job = AsyncMock(return_value=MagicMock(job_id="job-1")) + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock, + return_value="Hi"): + await _process_lapsed_patient(db, client, _canonical_appt(), pool) + + # The patients-chain .eq calls include ("nookal_id", "pat-001"). + patients_calls = db._patients_chain.eq.call_args_list + assert any(call.args == ("nookal_id", "pat-001") for call in patients_calls) + + +@pytest.mark.asyncio +async def test_practitioner_do_not_contact_skips_send(): + """Practitioner-level do_not_contact skips the SMS even when patient-level is false.""" + from jobs.appointment_followup import _process_lapsed_patient + + client = {"id": "cid", "booking_system": "cliniko", "business_name": "Clinic", "state": "NSW"} + # patient_dnc False, practitioner_dnc True + db = _mock_db(patient_dnc=False, practitioner_dnc=True) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock) as mock_gen: + await _process_lapsed_patient(db, client, _canonical_appt(), pool) + + mock_gen.assert_not_awaited() + pool.enqueue_job.assert_not_awaited() + # No appointments row written for a skipped patient. + db.table("appointments").insert.assert_not_called() + + +@pytest.mark.asyncio +async def test_patient_do_not_contact_skips_send(): + """Patient-level do_not_contact skips the SMS.""" + from jobs.appointment_followup import _process_lapsed_patient + + client = {"id": "cid", "booking_system": "cliniko", "business_name": "Clinic", "state": "NSW"} + db = _mock_db(patient_dnc=True) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock) as mock_gen: + await _process_lapsed_patient(db, client, _canonical_appt(), pool) + + mock_gen.assert_not_awaited() + pool.enqueue_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_claim_type_threaded_into_message_and_insert(): + """claim_type is threaded into generate_followup_message and written to appointments.""" + from jobs.appointment_followup import _process_lapsed_patient + + client = {"id": "cid", "booking_system": "cliniko", "business_name": "Clinic", "state": "NSW"} + db = _mock_db(patient_dnc=False) + pool = MagicMock() + pool.enqueue_job = AsyncMock(return_value=MagicMock(job_id="job-1")) + appt = _canonical_appt(claim={"type": "bulk_billed", "fund": "Medicare", "gap_amount": None}) + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock, + return_value="Msg") as mock_gen: + await _process_lapsed_patient(db, client, appt, pool) + + _, kwargs = mock_gen.call_args + assert kwargs["claim_type"] == "bulk_billed" + assert kwargs["practitioner_name"] == "Dr Chen" + + insert_data = db.table("appointments").insert.call_args.args[0] + assert insert_data["claim_type"] == "bulk_billed" + assert insert_data["claim_fund"] == "Medicare" + assert insert_data["practitioner_name"] == "Dr Chen" + assert insert_data["followup_message"] == "Msg" + + +@pytest.mark.asyncio +async def test_sms_enqueued_via_arq_not_direct(): + """Outbound SMS routes through the durable send_sms_task arq enqueue (C4), not a + direct twilio_sms.send_sms call.""" + from jobs.appointment_followup import _process_lapsed_patient + + client = {"id": "cid", "booking_system": "cliniko", "business_name": "Clinic", "state": "VIC"} + db = _mock_db(patient_dnc=False) + pool = MagicMock() + pool.enqueue_job = AsyncMock(return_value=MagicMock(job_id="SM-job-1")) + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock, + return_value="Hello"), \ + patch("services.twilio_sms.send_sms", new_callable=AsyncMock) as mock_direct: + await _process_lapsed_patient(db, client, _canonical_appt(), pool) + + mock_direct.assert_not_awaited() + pool.enqueue_job.assert_awaited_once() + assert pool.enqueue_job.call_args.args[0] == "send_sms_task" + # (to, body, state) forwarded positionally + assert pool.enqueue_job.call_args.args[1] == "+61412345678" + assert pool.enqueue_job.call_args.args[2] == "Hello" + assert pool.enqueue_job.call_args.args[3] == "VIC" + + insert_data = db.table("appointments").insert.call_args.args[0] + assert insert_data["followup_sid"] == "SM-job-1" + assert insert_data["followup_sent"] is True + + +@pytest.mark.asyncio +async def test_no_phone_skips_sms(): + """A patient without a phone number is skipped (no enqueue, no insert).""" + from jobs.appointment_followup import _process_lapsed_patient + + client = {"id": "cid", "booking_system": "cliniko", "business_name": "Clinic", "state": "NSW"} + db = _mock_db(patient_dnc=False) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + appt = _canonical_appt(patient_phone=None) + + with patch("jobs.appointment_followup.generate_followup_message", new_callable=AsyncMock, + return_value="Hi"): + await _process_lapsed_patient(db, client, appt, pool) + + pool.enqueue_job.assert_not_awaited() + db.table("appointments").insert.assert_not_called() + + +@pytest.mark.asyncio +async def test_practitioner_upsert_dedups_per_external_id(): + """_upsert_practitioners upserts one row per unique practitioner external_id.""" + from jobs.appointment_followup import _upsert_practitioners + + db = MagicMock() + upsert_chain = MagicMock() + db.table.return_value = upsert_chain + + appointments = [ + _canonical_appt(practitioner_id="prac-01", practitioner_name="Dr Chen"), + _canonical_appt(practitioner_id="prac-01", practitioner_name="Dr Chen"), # dup + _canonical_appt(practitioner_id="prac-02", practitioner_name="Dr Lee"), + _canonical_appt(practitioner_id=None, practitioner_name=None), # no practitioner + ] + await _upsert_practitioners(db, "cid", "cliniko", appointments) + + assert upsert_chain.upsert.call_count == 2 + upserted = {c.args[0]["external_id"]: c.args[0]["name"] for c in upsert_chain.upsert.call_args_list} + assert upserted == {"prac-01": "Dr Chen", "prac-02": "Dr Lee"} + for c in upsert_chain.upsert.call_args_list: + assert c.args[0]["booking_system"] == "cliniko" + assert c.args[0]["client_id"] == "cid" + assert c.kwargs["on_conflict"] == "client_id,booking_system,external_id" def test_au_holiday_skips_send(): @@ -73,3 +340,38 @@ def test_au_holiday_skips_send(): assert is_au_public_holiday(date(2026, 1, 1), "NSW") is True assert is_au_public_holiday(date(2026, 7, 17), "NSW") is False + + +@pytest.mark.asyncio +async def test_run_all_writes_adapter_unsupported_marker(): + """A client whose booking system is a stub gets an appointments marker row with + followup_error='adapter_unsupported' and is skipped (no identify call).""" + from jobs.appointment_followup import run_appointment_followup_all_clients + + db = MagicMock() + clients_chain = _chain_with([{ + "id": "c1", + "booking_system": "hotdoc", + "active_jobs": ["appointment_followup"], + }]) + insert_chain = MagicMock() + insert_chain.execute.return_value = MagicMock(data=[{}]) + + def table(name): + if name == "clients": + return clients_chain + if name == "appointments": + return insert_chain + return MagicMock() + + db.table.side_effect = table + + with patch("jobs.appointment_followup.get_db", return_value=db), \ + patch("services.hotdoc.get_appointments", new_callable=AsyncMock) as mock_get: + await run_appointment_followup_all_clients(arq_pool=None) + + # Marker row written; stub adapter never queried for appointments. + insert_data = insert_chain.insert.call_args.args[0] + assert insert_data["followup_error"] == "adapter_unsupported" + assert insert_data["client_id"] == "c1" + mock_get.assert_not_awaited() diff --git a/backend/tests/unit/test_stub_adapters.py b/backend/tests/unit/test_stub_adapters.py new file mode 100644 index 0000000..a4a12ed --- /dev/null +++ b/backend/tests/unit/test_stub_adapters.py @@ -0,0 +1,42 @@ +"""Tests for the fail-safe stub adapters (HotDoc, Jane, PracticePal). + +Stubs register with SUPPORTS_REBOOK=False, log a partner-gated warning, and return +[] so the daily Rebook job fails safe instead of 404ing. +""" +import logging + +import pytest + +from services import hotdoc, jane, practicepal + + +_STUBS = [ + (hotdoc, "hotdoc", "hotdoc_id", "partner_gated"), + (jane, "jane", "jane_id", "partner_gated"), + (practicepal, "practicepal", "practicepal_id", "none"), +] + + +@pytest.mark.parametrize("module,name,id_column,auth_model", _STUBS) +def test_stub_metadata(module, name, id_column, auth_model): + assert module.ADAPTER_NAME == name + assert module.ID_COLUMN == id_column + assert module.CREDENTIAL_KEYS == [] + assert module.SUPPORTS_REBOOK is False + assert module.AUTH_MODEL == auth_model + + +@pytest.mark.parametrize("module,name,id_column,auth_model", _STUBS) +@pytest.mark.asyncio +async def test_stub_get_appointments_returns_empty_and_logs(module, name, id_column, auth_model, caplog): + caplog.set_level(logging.WARNING, logger=module.__name__) + result = await module.get_appointments({"id": "c1"}, "2026-05-01", "2026-05-31") + assert result == [] + assert any(rec.levelno == logging.WARNING for rec in caplog.records) + + +@pytest.mark.parametrize("module,name,id_column,auth_model", _STUBS) +@pytest.mark.asyncio +async def test_stub_get_future_appointments_returns_empty(module, name, id_column, auth_model): + result = await module.get_future_appointments({"id": "c1"}, "p-1", "2026-07-22") + assert result == [] diff --git a/supabase/migrations/014_practitioners.sql b/supabase/migrations/014_practitioners.sql new file mode 100644 index 0000000..65a30ce --- /dev/null +++ b/supabase/migrations/014_practitioners.sql @@ -0,0 +1,27 @@ +-- 014_practitioners.sql +-- Practitioners table (Phase 2 — Clinical). First-class practitioner records so the +-- daily Rebook follow-up can reference the right clinician and honour per-practitioner +-- opt-outs. One row per (client, booking_system, external PMS practitioner id). +-- +-- Adapters UPSERT rows here from appointment results (C5) so opt-outs and the +-- dashboard have records even before a follow-up is ever sent. + +create table if not exists practitioners ( + id uuid primary key default gen_random_uuid(), + client_id uuid references clients(id) not null, + external_id text not null, -- PMS practitioner id + booking_system text not null, -- which PMS this id belongs to + name text, -- display name + do_not_contact boolean default false, -- suppress follow-ups routed to this practitioner + active boolean default true, + created_at timestamptz default now(), + unique(client_id, booking_system, external_id) +); + +alter table practitioners enable row level security; + +drop policy if exists "service_role_all" on practitioners; +create policy "service_role_all" on practitioners + using (auth.role() = 'service_role'); + +create index if not exists practitioners_client_idx on practitioners(client_id); diff --git a/supabase/migrations/015_patient_external_ids.sql b/supabase/migrations/015_patient_external_ids.sql new file mode 100644 index 0000000..c33d072 --- /dev/null +++ b/supabase/migrations/015_patient_external_ids.sql @@ -0,0 +1,25 @@ +-- 015_patient_external_ids.sql +-- Per-PMS patient id columns on `patients` (Phase 2 — Clinical). Lets the daily +-- Rebook follow-up look up `do_not_contact` via `adapter.ID_COLUMN` for the new +-- booking systems (cliniko_id / square_id already exist in 008). All nullable — a +-- patient only carries the id for the booking system they were seen under. + +alter table patients add column if not exists nookal_id text; +alter table patients add column if not exists halaxy_id text; +alter table patients add column if not exists hotdoc_id text; +alter table patients add column if not exists jane_id text; +alter table patients add column if not exists practicepal_id text; + +comment on column patients.nookal_id is 'External Nookal patient id (do_not_contact lookup)'; +comment on column patients.halaxy_id is 'External Halaxy (FHIR Patient) id'; +comment on column patients.hotdoc_id is 'External HotDoc patient id (partner-gated)'; +comment on column patients.jane_id is 'External Jane.app patient id (partner-gated)'; +comment on column patients.practicepal_id is 'External PracticePal patient id (partner-gated)'; + +-- Partial unique indexes: one patient per (client, external_id) per PMS. +-- Nullable columns excluded from the index so NULLs never conflict. +create unique index patients_client_nookal_idx on patients(client_id, nookal_id) where nookal_id is not null; +create unique index patients_client_halaxy_idx on patients(client_id, halaxy_id) where halaxy_id is not null; +create unique index patients_client_hotdoc_idx on patients(client_id, hotdoc_id) where hotdoc_id is not null; +create unique index patients_client_jane_idx on patients(client_id, jane_id) where jane_id is not null; +create unique index patients_client_practicepal_idx on patients(client_id, practicepal_id) where practicepal_id is not null; diff --git a/supabase/migrations/016_appointment_claim.sql b/supabase/migrations/016_appointment_claim.sql new file mode 100644 index 0000000..3639a49 --- /dev/null +++ b/supabase/migrations/016_appointment_claim.sql @@ -0,0 +1,30 @@ +-- 016_appointment_claim.sql +-- Claim metadata + follow-up bookkeeping columns on `appointments` (Phase 2 — Clinical). +-- +-- Includes the C6 latent-bug fix: the daily Rebook job already writes +-- `followup_error` / `followup_message` / `followup_sid` but the columns never +-- existed, so every follow-up insert silently failed (or relied on the caller +-- never reading them). They are added here with `add column if not exists` so the +-- job's insert succeeds. +-- +-- `practitioner_id` / `practitioner_name` are folded in here (the appointments +-- column migration) because the rewritten job writes the practitioner context per +-- follow-up row; without them the insert would reference non-existent columns. + +alter table appointments add column if not exists practitioner_id text; +alter table appointments add column if not exists practitioner_name text; + +-- Patient's usual / last-seen practitioner (plan: practitioner context lives on +-- patients, not just per-appointment). +alter table patients add column if not exists practitioner_id text; +alter table patients add column if not exists practitioner_name text; + +alter table appointments add column if not exists claim_type text; -- 'bulk_billed'|'gap'|'private_health'|'unknown' +alter table appointments add column if not exists claim_fund text; +alter table appointments add column if not exists claim_gap_amount numeric(10,2); + +alter table appointments add column if not exists followup_error text; +alter table appointments add column if not exists followup_message text; +alter table appointments add column if not exists followup_sid text; + +comment on column appointments.claim_type is 'Best-effort Medicare/health-fund claim type from PMS billing (D3-A: kept out of SMS copy)';