Ends the override leak: an SWO/PA save now applies to one device's line, not
every device the patient has. doc_status gains device_type ('' = legacy
patient-scoped row, applies until re-saved); the old unique key is dropped by
introspected name and replaced with the 4-col scope key. Staff overrides now
FEED THE VERDICT (device-scoped, staff wins over the CSV cell) instead of
display-only; visit overrides keep their patient-scoped fan-out via
confirmed_visits. PUT /api/doc-status and /api/confirm-visit accept an
optional echo of the patient's lines and return {lines, rollup_status}
recomputed through the same engine (stateless, no batch rebuild). Frontend
minimal fix: doc-status state and writes keyed patient:device. Independent
audit: SHIP (migration reproduced empirically on both old-key shapes; guard
scoped per its nit). 162 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""
|
|
Supabase persistence for Signal upload batches, scored records, and report runs.
|
|
|
|
All writes are best-effort: failures are logged but never surface to the API caller.
|
|
The core scoring pipeline works without Supabase (dev mode / env vars not set).
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from datetime import date
|
|
|
|
from core.supabase_client import get_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEMO_ORG_SLUG = "gaboro-pilot"
|
|
_demo_org_id: str | None = None
|
|
|
|
|
|
def _sha256(value: str) -> str:
|
|
return hashlib.sha256(value.encode()).hexdigest()
|
|
|
|
|
|
def _get_or_create_org(clerk_org_id: str | None = None) -> str | None:
|
|
"""
|
|
Look up the Supabase org UUID.
|
|
If clerk_org_id is provided (from a verified Clerk JWT), look up by that first.
|
|
Falls back to the demo slug for API-key / dev sessions.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return None
|
|
|
|
try:
|
|
if clerk_org_id:
|
|
# Production path: look up by the Clerk org ID stored on the org record
|
|
result = client.table("organizations").select("id").eq("clerk_org_id", clerk_org_id).execute()
|
|
if result.data:
|
|
return result.data[0]["id"]
|
|
# Org exists in Clerk but not yet provisioned in Supabase — create it
|
|
result = client.table("organizations").insert({
|
|
"name": f"Org {clerk_org_id}",
|
|
"slug": clerk_org_id,
|
|
"clerk_org_id": clerk_org_id,
|
|
}).execute()
|
|
org_id = result.data[0]["id"]
|
|
logger.info(f"Auto-provisioned org for Clerk org {clerk_org_id}: {org_id}")
|
|
return org_id
|
|
|
|
# Fallback: demo slug (API key auth or dev mode)
|
|
global _demo_org_id
|
|
if _demo_org_id:
|
|
return _demo_org_id
|
|
result = client.table("organizations").select("id").eq("slug", DEMO_ORG_SLUG).execute()
|
|
if result.data:
|
|
_demo_org_id = result.data[0]["id"]
|
|
return _demo_org_id
|
|
result = client.table("organizations").insert({
|
|
"name": "Gaboro DME — Pilot",
|
|
"slug": DEMO_ORG_SLUG,
|
|
}).execute()
|
|
_demo_org_id = result.data[0]["id"]
|
|
logger.info(f"Created pilot org: {_demo_org_id}")
|
|
return _demo_org_id
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get/create org: {e}")
|
|
return None
|
|
|
|
|
|
def persist_upload(
|
|
filename: str,
|
|
content_bytes: bytes,
|
|
shipment_records: list,
|
|
coverage_results: list,
|
|
skipped_count: int,
|
|
mapping_summary: dict,
|
|
clerk_org_id: str | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Persist one upload batch and all related records to Supabase.
|
|
Returns the batch_id UUID string, or None if persistence is unavailable.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return None
|
|
|
|
org_id = _get_or_create_org(clerk_org_id=clerk_org_id)
|
|
if not org_id:
|
|
return None
|
|
|
|
try:
|
|
# 1. Upload batch
|
|
batch_res = client.table("upload_batches").insert({
|
|
"org_id": org_id,
|
|
"filename": filename,
|
|
"row_count": len(coverage_results),
|
|
"skipped_count": skipped_count,
|
|
"status": "complete",
|
|
}).execute()
|
|
batch_id = batch_res.data[0]["id"]
|
|
|
|
# 2. Source file metadata
|
|
content_hash = _sha256(content_bytes.decode("utf-8", errors="replace"))
|
|
client.table("source_files").insert({
|
|
"batch_id": batch_id,
|
|
"filename": filename,
|
|
"content_hash": content_hash,
|
|
"byte_size": len(content_bytes),
|
|
}).execute()
|
|
|
|
# 3. Normalized records — one row per scored patient
|
|
# shipment_records and coverage_results are same-indexed
|
|
qty_map = {sr.patient_id: sr.quantity for sr in shipment_records}
|
|
norm_rows = []
|
|
for r in coverage_results:
|
|
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
|
norm_rows.append({
|
|
"batch_id": batch_id,
|
|
"patient_id_hash": _sha256(r.patient_id),
|
|
"device_type": r.device_type,
|
|
"shipment_date": r.last_shipment_date.isoformat(),
|
|
"quantity": qty_map.get(r.patient_id, 1),
|
|
"payer": r.payer,
|
|
"component": r.component,
|
|
"coverage_status": flag_val,
|
|
"days_remaining": r.days_until_coverage_end,
|
|
"rule_version": r.rule_version,
|
|
})
|
|
if norm_rows:
|
|
client.table("normalized_records").insert(norm_rows).execute()
|
|
|
|
# 4. Mapping decisions — how each CSV header was resolved
|
|
mapping_rows = []
|
|
for canonical, detail in mapping_summary.get("mapped", {}).items():
|
|
mapping_rows.append({
|
|
"batch_id": batch_id,
|
|
"raw_header": detail["raw_header"],
|
|
"canonical_field": canonical,
|
|
"confidence": detail["confidence"],
|
|
})
|
|
for raw_h in mapping_summary.get("unmapped_columns", []):
|
|
mapping_rows.append({
|
|
"batch_id": batch_id,
|
|
"raw_header": raw_h,
|
|
"canonical_field": None,
|
|
"confidence": "unmapped",
|
|
})
|
|
if mapping_rows:
|
|
client.table("mapping_decisions").insert(mapping_rows).execute()
|
|
|
|
# 5. Report run summary
|
|
_no_action_flags = {"OK", "ACTIVE", "RESUPPLY_READY"}
|
|
flagged = sum(
|
|
1 for r in coverage_results
|
|
if (r.flag.value if hasattr(r.flag, "value") else str(r.flag)) not in _no_action_flags
|
|
)
|
|
client.table("report_runs").insert({
|
|
"batch_id": batch_id,
|
|
"org_id": org_id,
|
|
"status": "complete",
|
|
"total_records": len(coverage_results),
|
|
"flagged_count": flagged,
|
|
}).execute()
|
|
|
|
logger.info(f"Persisted batch {batch_id}: {len(coverage_results)} records, {flagged} flagged")
|
|
return batch_id
|
|
|
|
except Exception as e:
|
|
logger.error(f"Persistence error on upload '{filename}': {e}")
|
|
return None
|
|
|
|
|
|
def upsert_confirmed_visit(
|
|
org_id: str,
|
|
patient_id_hash: str,
|
|
confirmed_date: date,
|
|
confirmed_by: str = "staff",
|
|
) -> bool:
|
|
"""
|
|
Insert or update a confirmed visit date for a patient.
|
|
Returns True on success, False if Supabase unavailable.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return False
|
|
try:
|
|
client.table("confirmed_visits").upsert({
|
|
"org_id": org_id,
|
|
"patient_id_hash": patient_id_hash,
|
|
"confirmed_date": confirmed_date.isoformat(),
|
|
"confirmed_by": confirmed_by,
|
|
"updated_at": "now()",
|
|
}, on_conflict="org_id,patient_id_hash").execute()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to upsert confirmed visit: {e}")
|
|
return False
|
|
|
|
|
|
def load_confirmed_visits_for_org(org_id: str) -> dict:
|
|
"""
|
|
Load all confirmed visit dates for an org.
|
|
Returns dict mapping patient_id_hash -> confirmed_date.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return {}
|
|
try:
|
|
result = client.table("confirmed_visits") \
|
|
.select("patient_id_hash,confirmed_date") \
|
|
.eq("org_id", org_id) \
|
|
.execute()
|
|
return {
|
|
row["patient_id_hash"]: date.fromisoformat(row["confirmed_date"])
|
|
for row in (result.data or [])
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to load confirmed visits: {e}")
|
|
return {}
|
|
|
|
|
|
def upsert_doc_status(
|
|
org_id: str,
|
|
patient_id_hash: str,
|
|
doc_type: str,
|
|
status: str,
|
|
status_date: "date | None" = None,
|
|
expiry_date: "date | None" = None,
|
|
device_type: str = "",
|
|
) -> bool:
|
|
"""
|
|
Insert or update a doc status (swo or pa), scoped to a device.
|
|
device_type '' writes a legacy patient-scoped row (applies to all of the
|
|
patient's devices until a device-scoped row is saved).
|
|
Returns True on success, False if Supabase unavailable.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return False
|
|
try:
|
|
payload = {
|
|
"org_id": org_id,
|
|
"patient_id_hash": patient_id_hash,
|
|
"doc_type": doc_type,
|
|
"status": status,
|
|
"device_type": device_type or "",
|
|
"updated_at": "now()",
|
|
}
|
|
if status_date is not None:
|
|
payload["status_date"] = status_date.isoformat()
|
|
if expiry_date is not None:
|
|
payload["expiry_date"] = expiry_date.isoformat()
|
|
client.table("doc_status").upsert(
|
|
payload, on_conflict="org_id,patient_id_hash,doc_type,device_type"
|
|
).execute()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to upsert doc_status: {e}")
|
|
return False
|
|
|
|
|
|
def load_doc_statuses_for_org(org_id: str) -> dict:
|
|
"""
|
|
Load all SWO and PA statuses for an org, device-scoped.
|
|
Returns dict: patient_id_hash -> device_type -> {doc_type: {status,
|
|
status_date, expiry_date}}. The '' device bucket holds legacy
|
|
patient-scoped rows (they apply to every device until re-saved).
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return {}
|
|
try:
|
|
result = client.table("doc_status") \
|
|
.select("patient_id_hash,device_type,doc_type,status,status_date,expiry_date") \
|
|
.eq("org_id", org_id) \
|
|
.execute()
|
|
out: dict = {}
|
|
for row in (result.data or []):
|
|
h = row["patient_id_hash"]
|
|
device = row.get("device_type") or ""
|
|
out.setdefault(h, {}).setdefault(device, {})[row["doc_type"]] = {
|
|
"status": row["status"],
|
|
"status_date": row.get("status_date"),
|
|
"expiry_date": row.get("expiry_date"),
|
|
}
|
|
return out
|
|
except Exception as e:
|
|
logger.error(f"Failed to load doc_statuses: {e}")
|
|
return {}
|
|
|
|
|
|
def get_or_create_org(clerk_org_id: str | None = None) -> str | None:
|
|
"""Public wrapper around _get_or_create_org for use by the API."""
|
|
return _get_or_create_org(clerk_org_id=clerk_org_id)
|
|
|
|
|
|
def persist_export(batch_id: str | None, filename: str, row_count: int) -> None:
|
|
"""Record that a work queue CSV was exported. Best-effort."""
|
|
if not batch_id:
|
|
return
|
|
|
|
client = get_client()
|
|
if not client:
|
|
return
|
|
|
|
try:
|
|
# Find the report_run for this batch
|
|
run_res = client.table("report_runs").select("id").eq("batch_id", batch_id).execute()
|
|
if not run_res.data:
|
|
return
|
|
run_id = run_res.data[0]["id"]
|
|
|
|
client.table("export_files").insert({
|
|
"report_run_id": run_id,
|
|
"filename": filename,
|
|
"row_count": row_count,
|
|
}).execute()
|
|
except Exception as e:
|
|
logger.error(f"Persistence error on export: {e}")
|