diff --git a/scripts/health/assets/health-assets/dashboard-v5-record.js b/scripts/health/assets/health-assets/dashboard-v5-record.js index 4ab3635..b33263e 100644 --- a/scripts/health/assets/health-assets/dashboard-v5-record.js +++ b/scripts/health/assets/health-assets/dashboard-v5-record.js @@ -738,8 +738,10 @@ } const medicationDose = item => { - const actual = [item.actual_dose_value, item.actual_dose_unit].filter(Boolean).join(' '); - const planned = [item.planned_dose_value, item.planned_dose_unit].filter(Boolean).join(' '); + const actualStructured = [item.actual_quantity_value,item.actual_dosage_form].filter(Boolean).join(' ')+(item.actual_strength?` · ${item.actual_strength}`:''); + const plannedStructured = [item.planned_quantity_value,item.planned_dosage_form].filter(Boolean).join(' ')+(item.planned_strength?` · ${item.planned_strength}`:''); + const actual = actualStructured || [item.actual_dose_value, item.actual_dose_unit].filter(Boolean).join(' '); + const planned = plannedStructured || [item.planned_dose_value, item.planned_dose_unit].filter(Boolean).join(' '); if(item.status==='administered') return actual || (item.legacy_dose ? `Legacy-Freitext (nicht als tatsächliche Dosis strukturiert): ${item.legacy_dose}` : 'Tatsächliche Dosis nicht dokumentiert'); if(item.status==='planned') return planned || (item.legacy_dose ? `Legacy-Freitext (nicht strukturiert): ${item.legacy_dose}` : 'Geplante Dosis nicht dokumentiert'); if(item.status==='corrected') { @@ -811,19 +813,76 @@ dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();when.focus(); } + function administrationCaptureDialog(prescription, eventItem = null, captureMode = 'historical') { + const trigger=document.activeElement; + const plannedMode=captureMode==='planned'; + if(plannedMode&&!eventItem)return; + const baseline=plannedMode?eventItem?.administration_preset:prescription.administration_preset; + const dialog=document.createElement('dialog');dialog.className='medication-action-dialog';dialog.setAttribute('aria-labelledby','medication-administration-title'); + const form=document.createElement('form');form.method='dialog'; + const title=element('h2',plannedMode?'Geplanten Termin dokumentieren':'Historische Einnahme/Gabe erfassen');title.id='medication-administration-title'; + form.append(title,element('p','Manuelle Dokumentation ohne Änderung des Verordnungsstatus.','v5-meta')); + if(prescription.status!=='active')form.append(element('p',`Verordnungsstatus bleibt: ${prescription.status==='unknown'?'unbekannt':prescription.status}.`,'v5-meta')); + const when=document.createElement('input');when.type='datetime-local';when.required=true; + const quantity=document.createElement('input');quantity.maxLength=40;quantity.required=true;quantity.inputMode='decimal';quantity.value=baseline?.quantity_value||''; + const dosageForm=document.createElement('input');dosageForm.maxLength=40;dosageForm.required=true;dosageForm.value=baseline?.dosage_form||''; + const strength=document.createElement('input');strength.maxLength=80;strength.required=true;strength.value=baseline?.strength||''; + const route=document.createElement('select');[['unknown','Nicht belegt'],['oral','Oral'],['subcutaneous','Subkutan'],['intravenous','Intravenös'],['intramuscular','Intramuskulär'],['topical','Äußerlich'],['inhaled','Inhalativ'],['other','Andere dokumentierte Route']].forEach(([value,label])=>{const option=element('option',label);option.value=value;route.append(option);});route.value=baseline?.route_normalized||'unknown'; + const routeOriginal=document.createElement('input');routeOriginal.maxLength=60;routeOriginal.value=baseline?.route_original||''; + const region=document.createElement('input');region.maxLength=80;const side=document.createElement('select');[['','Nicht angegeben'],['left','Links'],['right','Rechts'],['unspecified','Seite nicht angegeben']].forEach(([value,label])=>{const option=element('option',label);option.value=value;side.append(option);});const injectionDetail=document.createElement('input');injectionDetail.maxLength=120; + const note=document.createElement('textarea');note.maxLength=300;const deviation=document.createElement('input');deviation.type='checkbox';const duplicate=document.createElement('input');duplicate.type='checkbox'; + form.append(formRow('Medikament',element('strong',prescription.name)),formRow('Zeitpunkt (Europe/Zurich)',when),formRow('Menge',quantity),formRow('Darreichungsform',dosageForm),formRow('Wirkstoffstärke',strength),formRow('Applikationsweg',route),formRow('Applikationsweg – dokumentierte Originalangabe',routeOriginal),formRow('Injektionsregion (optional)',region),formRow('Seite (optional)',side),formRow('Injektionsstelle (optional)',injectionDetail),formRow('Notiz (optional)',note)); + const deviationRow=formRow('Abweichung von der verifizierten Vorauswahl bewusst bestätigen',deviation);deviationRow.hidden=true;form.append(deviationRow,formRow('Möglichen identischen Doppeleintrag nach Prüfung zulassen',duplicate)); + const fieldError=element('p','', 'capture-inline-status');fieldError.setAttribute('role','alert');form.append(fieldError); + const preview=element('section',undefined,'medication-action-preview');preview.hidden=true;preview.setAttribute('aria-live','polite');form.append(preview); + const controls=element('div',undefined,'record-actions');const cancel=button('Abbrechen',()=>dialog.close());const submit=button('Vorschau prüfen',()=>form.requestSubmit());controls.append(cancel,submit);form.append(controls);dialog.append(form);document.body.append(dialog); + const injectionRoutes=new Set(['subcutaneous','intravenous','intramuscular','other']); + const syncInjection=()=>{const enabled=injectionRoutes.has(route.value);[region,side,injectionDetail].forEach(input=>{input.disabled=!enabled;if(!enabled)input.value='';});}; + const differs=()=>Boolean(baseline)&&[quantity.value.trim()!==baseline.quantity_value,dosageForm.value.trim()!==baseline.dosage_form,strength.value.trim()!==baseline.strength,routeOriginal.value.trim()!==(baseline.route_original||''),route.value!==(baseline.route_normalized||'unknown')].some(Boolean); + const syncDeviation=()=>{deviationRow.hidden=!differs();if(deviationRow.hidden)deviation.checked=false;}; + [quantity,dosageForm,strength,routeOriginal,route].forEach(input=>input.addEventListener('input',syncDeviation));route.addEventListener('change',()=>{routeOriginal.value=baseline&&route.value===baseline.route_normalized?(baseline.route_original||''):'';syncInjection();syncDeviation();});syncInjection();syncDeviation(); + let frozen=null;let frozenCsrf=''; + form.addEventListener('input',()=>{fieldError.textContent='';if(frozen){frozen=null;frozenCsrf='';preview.hidden=true;submit.textContent='Vorschau prüfen';}}); + form.addEventListener('submit',async event=>{event.preventDefault();fieldError.textContent='';if(!frozen){ + if(differs()&&!deviation.checked){fieldError.textContent='Die Angaben weichen von der verifizierten Vorauswahl ab. Bitte Abweichung bestätigen.';deviation.focus();return;} + const data={contract:'health.medication_action.v2',mode:captureMode,status:'administered',medication_ref:prescription.id,planned_event_ref:plannedMode?eventItem.id:'',name:prescription.name,quantity_value:quantity.value.trim(),dosage_form:dosageForm.value.trim(),strength:strength.value.trim(),route_original:routeOriginal.value.trim(),route_normalized:route.value,injection_region:region.value.trim(),injection_side:side.value,injection_detail:injectionDetail.value.trim(),note:note.value.trim(),preset_revision:baseline?.preset_revision||'',preview_revision:'',deviation_confirmed:deviation.checked,duplicate_confirmed:duplicate.checked}; + const draft={version:1,action:'capture_entry',capture_type:'medication',request_version:1,idempotency_key:[...crypto.getRandomValues(new Uint8Array(16))].map(value=>value.toString(16).padStart(2,'0')).join(''),occurred_at:when.value,ended_at:null,data,attachments:[],corrects_entry_id:null,withdraws_entry_id:null}; + submit.disabled=true; + try{ + const csrfResponse=await fetch('/api/v1/capture/csrf',{credentials:'same-origin',headers:{'Accept':'application/json'}});if(!csrfResponse.ok)throw new Error('csrf');const csrfResult=await csrfResponse.json();const captureCsrf=String(csrfResult.csrf_token||'');if(!/^[A-Za-z0-9_-]{24,128}$/.test(captureCsrf))throw new Error('csrf'); + const body=new URLSearchParams({csrf_token:captureCsrf,return_to:'v5',payload:JSON.stringify(draft)});const response=await fetch('/health-actions/medication-preview',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()});const result=await response.json().catch(()=>({}));if(!response.ok){fieldError.textContent=result?.error?.message||'Die Angaben sind ungültig. Bitte markierte Felder prüfen.';const field=result?.error?.field;const fieldInputs={medication:quantity,quantity_value:quantity,dosage_form:dosageForm,strength,route_original:routeOriginal,route_normalized:route,injection_region:region,injection_side:side,injection_detail:injectionDetail,note,capture_mode:when,deviation_confirmed:deviation,duplicate_confirmed:duplicate,preset:quantity,preview:submit};fieldInputs[field]?.focus();return;}if(!/^[a-f0-9]{64}$/.test(String(result.preview_revision||'')))throw new Error('preview');draft.data.preview_revision=result.preview_revision;frozen=draft;frozenCsrf=captureCsrf; + }catch(_error){fieldError.textContent='Die Erfassung ist technisch nicht verfügbar. Es wurde nichts vorgemerkt.';return;}finally{submit.disabled=false;} + preview.replaceChildren(element('h3','Vollständige Vorschau'),element('p',`${when.value} · ${prescription.name}`),element('p',`${quantity.value} ${dosageForm.value} · ${strength.value}`),element('p',`Applikationsweg: ${route.options[route.selectedIndex].text}${region.value||side.value||injectionDetail.value?` · ${[region.value,side.options[side.selectedIndex]?.text,injectionDetail.value].filter(Boolean).join(' · ')}`:''}`),element('p',note.value?`Notiz: ${note.value}`:'Keine Notiz.','v5-meta'),element('p',plannedMode?'Mit vorhandenem Plantermin verknüpft.':'Historische manuelle Gabe ohne Plantermin.','v5-meta'));preview.hidden=false;submit.textContent='Verbindlich vormerken';return; + } + submit.disabled=true; + try{ + if(!frozenCsrf){const csrfResponse=await fetch('/api/v1/capture/csrf',{credentials:'same-origin',headers:{'Accept':'application/json'}});if(!csrfResponse.ok)throw new Error('csrf');const csrfResult=await csrfResponse.json();frozenCsrf=String(csrfResult.csrf_token||'');if(!/^[A-Za-z0-9_-]{24,128}$/.test(frozenCsrf))throw new Error('csrf');} + const body=new URLSearchParams({csrf_token:frozenCsrf,return_to:'v5',payload:JSON.stringify(frozen)}); + const response=await fetch('/health-actions/capture',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()}); + const result=await response.json().catch(()=>({})); + if(!response.ok||result.status!=='queued')throw new Error('capture_unavailable'); + fieldError.textContent='Vorgemerkt. Die lokale Verarbeitung steht noch aus.';submit.textContent='Vorgemerkt'; + const statusKey=String(result.status_key||'');const statusToken=String(result.status_token||''); + if(!/^[a-f0-9]{32}$/.test(statusKey)||!/^[a-f0-9]{64}$/.test(statusToken))throw new Error('status_binding'); + let attempts=0;const poll=async()=>{attempts+=1;try{const check=await fetch(`/api/v1/capture/status/${statusKey}/${statusToken}`,{credentials:'same-origin',headers:{'Accept':'application/json'}});const state=await check.json();if(state.status==='processed'){fieldError.textContent='Gespeichert.';setTimeout(()=>{dialog.close();render('medications',{push:false});},300);return;}if(state.status==='rejected'){fieldError.textContent='Die Verarbeitung wurde wegen eines geänderten Datenstands oder eines möglichen Duplikats abgelehnt. Eingaben bleiben erhalten; bitte Vorschau erneut prüfen.';frozen=null;frozenCsrf='';preview.hidden=true;submit.disabled=false;submit.textContent='Vorschau erneut prüfen';return;}}catch(_error){}if(attempts<8)setTimeout(poll,750);};setTimeout(poll,750); + }catch(_error){fieldError.textContent='Die Erfassung ist technisch nicht verfügbar. Eingaben und identische Aktion bleiben erhalten; bitte erneut senden.';frozenCsrf='';submit.disabled=false;submit.textContent='Erneut senden';} + }); + dialog.addEventListener('close',()=>{dialog.remove();if(trigger instanceof HTMLElement)trigger.focus();},{once:true});dialog.showModal();when.focus(); + } + async function renderMedications(filters = {}, current) { const node = card('Medikamente'); const safeFilters=sanitizeFilters('medications',filters); const data = await request(`/api/v1/medications?${query(safeFilters)}`, current); const form=element('form',undefined,'record-filter-form');const from=document.createElement('input');from.type='date';from.value=safeFilters.from||'';const to=document.createElement('input');to.type='date';to.value=safeFilters.to||'';const medication=document.createElement('select');[['','Alle Medikamente'],...(data.prescriptions||[]).map(item=>[item.id,item.name])].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.medication===value;medication.append(option);});const eventStatus=document.createElement('select');[['','Alle Status'],['planned','Geplant'],['administered','Verabreicht / eingenommen'],['missed','Ausgelassen'],['corrected','Korrigiert'],['unknown','Unbekannt']].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.status===value;eventStatus.append(option);});const source=document.createElement('select');[['','Alle Quellen'],...(data.sources||[]).map(value=>[value,sourceLabel(value)])].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.source===value;source.append(option);});form.append(formRow('Von',from),formRow('Bis',to),formRow('Medikament',medication),formRow('Status',eventStatus),formRow('Quelle',source),button('Filter anwenden',()=>form.requestSubmit()));form.addEventListener('submit',event=>{event.preventDefault();render('medications',{push:true,filters:{from:from.value,to:to.value,medication:medication.value,status:eventStatus.value,source:source.value}});});node.append(form); - const currentSection=element('section',undefined,'medication-current');currentSection.append(element('h4','Aktive Verordnungen'));(data.current_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');article.append(element('strong',item.name),element('span','Aktiv','status-chip'),element('p',item.documented_dose||'Dosis nicht strukturiert dokumentiert','v5-meta'));article.append(button('Jetzt als eingenommen / verabreicht dokumentieren',()=>medicationActionDialog(item)));currentSection.append(article);});if(!(data.current_prescriptions||[]).length)appendEmpty(currentSection);node.append(currentSection); - const otherSection=element('section',undefined,'medication-other-prescriptions');otherSection.append(element('h4','Pausierte, beendete oder unklare Verordnungen'));(data.other_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');const label=item.status==='paused'?'Pausiert':item.status==='ended'?'Beendet':'Status unbekannt';article.append(element('strong',item.name),element('span',label,'status-chip'),element('p','Keine Darstellung als aktuell aktive Verordnung.','v5-meta'));otherSection.append(article);});if(!(data.other_prescriptions||[]).length)appendEmpty(otherSection);node.append(otherSection,element('h4','Verlauf')); + const currentSection=element('section',undefined,'medication-current');currentSection.append(element('h4','Aktive Verordnungen'));(data.current_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');article.append(element('strong',item.name),element('span','Aktiv','status-chip'),element('p',item.documented_dose||'Dosis nicht strukturiert dokumentiert','v5-meta'));article.append(button('Historische Einnahme/Gabe erfassen',()=>administrationCaptureDialog(item,null,'historical')));currentSection.append(article);});if(!(data.current_prescriptions||[]).length)appendEmpty(currentSection);node.append(currentSection); + const otherSection=element('section',undefined,'medication-other-prescriptions');otherSection.append(element('h4','Pausierte, beendete oder unklare Verordnungen'));(data.other_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');const label=item.status==='paused'?'Pausiert':item.status==='ended'?'Beendet':'Status unbekannt';article.append(element('strong',item.name),element('span',label,'status-chip'),element('p','Keine Darstellung als aktuell aktive Verordnung. Historische Dokumentation ändert diesen Status nicht.','v5-meta'),button('Historische Einnahme/Gabe erfassen',()=>administrationCaptureDialog(item,null,'historical')));otherSection.append(article);});if(!(data.other_prescriptions||[]).length)appendEmpty(otherSection);node.append(otherSection,element('h4','Verlauf')); const prescriptionsById=new Map((data.prescriptions||[]).map(item=>[item.id,item])); ['planned', 'administered', 'missed', 'corrected', 'unknown'].forEach(kind => { const section = element('section'); section.append(element('h5', ({ planned: 'Geplant', administered: 'Tatsächlich verabreicht / eingenommen', missed: 'Ausgelassen', corrected: 'Korrigiert',unknown:'Status unbekannt' })[kind])); const rows = data[kind] || []; if(!rows.length){section.append(element('p','Keine dokumentierten Angaben.'));node.append(section);return;} - const list=element('div',undefined,'medication-history-list');rows.forEach(item=>{const article=element('article',undefined,'medication-history-row');article.append(element('div',`${item.date} · ${item.name} · ${medicationDose(item)} · ${sourceLabel(item.source)}`));if(item.superseded_by_correction)article.append(element('span','Durch Korrektur ersetzt','status-chip'));const details=document.createElement('details');details.append(element('summary','Details'));const facts=element('dl');[['Status',item.status],['Dokumentierter Folgetermin',item.scheduled_next_date||'Nicht angegeben'],['Geplante Dosis',[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['Tatsächliche Dosis',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['Legacy-Dosisfreitext (uninterpretiert)',item.legacy_dose||'Nicht vorhanden'],['Applikationsweg (Original)',item.route_original||'Nicht angegeben'],['Applikationsweg (Anzeige)',item.route_normalized||'Unbekannt'],['Injektionsstelle',[item.injection_region,item.injection_side,item.injection_detail].filter(Boolean).join(' · ')||'Nicht angegeben'],['Charge',item.lot_number||'Nicht angegeben'],['Notiz',item.note||'Nicht angegeben'],['Korrekturbegründung',item.correction_reason||'Nicht angegeben'],['Quelle',sourceLabel(item.source)]].forEach(([label,value])=>facts.append(element('dt',label),element('dd',value)));details.append(facts);article.append(details);const prescription=prescriptionsById.get(item.prescription_id);if(prescription&&kind==='planned'&&!item.plan_consumed){article.append(button('Als eingenommen / verabreicht dokumentieren',()=>medicationActionDialog(prescription,item,'administered')),button('Auslassung dokumentieren',()=>medicationActionDialog(prescription,item,'missed')));}if(prescription&&!item.superseded_by_correction&&item.correction_preview_revision)article.append(button('Eintrag korrigieren',()=>medicationActionDialog(prescription,item,'corrected')));list.append(article);});section.append(list);node.append(section); + const list=element('div',undefined,'medication-history-list');rows.forEach(item=>{const article=element('article',undefined,'medication-history-row');article.append(element('div',`${item.date} · ${item.name} · ${medicationDose(item)} · ${sourceLabel(item.source)}`));if(item.superseded_by_correction)article.append(element('span','Durch Korrektur ersetzt','status-chip'));const details=document.createElement('details');details.append(element('summary','Details'));const facts=element('dl');[['Status',item.status],['Dokumentierter Folgetermin',item.scheduled_next_date||'Nicht angegeben'],['Geplante Menge',item.planned_quantity_value||'Nicht strukturiert dokumentiert'],['Geplante Darreichungsform',item.planned_dosage_form||'Nicht strukturiert dokumentiert'],['Geplante Wirkstoffstärke',item.planned_strength||'Nicht strukturiert dokumentiert'],['Tatsächliche Menge',item.actual_quantity_value||'Nicht strukturiert dokumentiert'],['Tatsächliche Darreichungsform',item.actual_dosage_form||'Nicht strukturiert dokumentiert'],['Tatsächliche Wirkstoffstärke',item.actual_strength||'Nicht strukturiert dokumentiert'],['Legacy-Dosis Wert/Einheit (uninterpretiert)',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht vorhanden'],['Legacy-Dosisfreitext (uninterpretiert)',item.legacy_dose||'Nicht vorhanden'],['Applikationsweg (Original)',item.route_original||'Nicht angegeben'],['Applikationsweg (Anzeige)',item.route_normalized||'Unbekannt'],['Injektionsstelle',[item.injection_region,item.injection_side,item.injection_detail].filter(Boolean).join(' · ')||'Nicht angegeben'],['Charge',item.lot_number||'Nicht angegeben'],['Notiz',item.note||'Nicht angegeben'],['Korrekturbegründung',item.correction_reason||'Nicht angegeben'],['Quelle',sourceLabel(item.source)]].forEach(([label,value])=>facts.append(element('dt',label),element('dd',value)));details.append(facts);article.append(details);const prescription=prescriptionsById.get(item.prescription_id);if(prescription&&kind==='planned'&&!item.plan_consumed){if(item.administration_preset)article.append(button('Geplanten Termin dokumentieren',()=>administrationCaptureDialog(prescription,item,'planned')));article.append(button('Auslassung dokumentieren',()=>medicationActionDialog(prescription,item,'missed')));}if(prescription&&!item.superseded_by_correction&&item.correction_preview_revision)article.append(button('Eintrag korrigieren',()=>medicationActionDialog(prescription,item,'corrected')));list.append(article);});section.append(list);node.append(section); }); node.append(element('p', `Datenvollständigkeit: ${data.truncated ? `gekürzt (${data.truncated_sections.join(', ')})` : 'vollständige Seite'} · Fehlende Legacy-Felder bleiben unbekannt.`, 'v5-meta')); return node; diff --git a/scripts/health/dashboard_v5/medication_contract.py b/scripts/health/dashboard_v5/medication_contract.py index 1d45091..9f8c6c6 100644 --- a/scripts/health/dashboard_v5/medication_contract.py +++ b/scripts/health/dashboard_v5/medication_contract.py @@ -9,7 +9,9 @@ import sqlite3 from typing import Any, Mapping PUBLIC_CONTRACT_VERSION = "health.medication_history.v1" -ACTION_CONTRACT_VERSION = "health.medication_action.v1" +ACTION_CONTRACT_VERSION_V1 = "health.medication_action.v1" +ACTION_CONTRACT_VERSION = "health.medication_action.v2" +ACTION_CONTRACT_VERSIONS = frozenset({ACTION_CONTRACT_VERSION_V1, ACTION_CONTRACT_VERSION}) ROUTES = frozenset( { "oral", @@ -57,6 +59,12 @@ EVENT_FIELDS = ( "corrected_target_status", "correction_reason", "business_revision", + "planned_quantity_value", + "planned_dosage_form", + "planned_strength", + "actual_quantity_value", + "actual_dosage_form", + "actual_strength", ) PRESCRIPTION_FIELDS = ( "id", @@ -68,9 +76,24 @@ PRESCRIPTION_FIELDS = ( "prescription_status_source", "prescription_status_provenance", "business_revision", + "administration_preset_quantity_value", + "administration_preset_dosage_form", + "administration_preset_strength", + "administration_preset_route_original", + "administration_preset_route", + "administration_preset_source", + "administration_preset_provenance", + "administration_preset_revision", ) +class MedicationFieldError(ValueError): + def __init__(self, field: str, code: str = "invalid") -> None: + super().__init__(f"invalid medication field: {field}") + self.field = field + self.code = code + + def _canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) @@ -116,6 +139,81 @@ def event_ref(row: sqlite3.Row | Mapping[str, Any], key: bytes) -> str: )[:24] +def prescription_preset( + row: sqlite3.Row | Mapping[str, Any], key: bytes +) -> dict[str, str] | None: + fields = ( + "administration_preset_quantity_value", + "administration_preset_dosage_form", + "administration_preset_strength", + "administration_preset_source", + "administration_preset_provenance", + "administration_preset_revision", + ) + if any(not _value(row, field) for field in fields): + return None + route = str(_value(row, "administration_preset_route") or "") + route_original = str(_value(row, "administration_preset_route_original") or "") + if route and (route not in ROUTES or not route_original): + return None + revision = str(_value(row, "administration_preset_revision")) + if not REVISION_RE.fullmatch(revision): + return None + revision_values = ( + str(_value(row, "administration_preset_quantity_value")), + str(_value(row, "administration_preset_dosage_form")), + str(_value(row, "administration_preset_strength")), + route_original, + route, + str(_value(row, "administration_preset_source")), + str(_value(row, "administration_preset_provenance")), + ) + expected_revision = hashlib.sha256( + (str(_value(row, "medikament_name")) + "\0" + "\0".join(revision_values)).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(revision, expected_revision): + return None + return { + "quantity_value": str(_value(row, "administration_preset_quantity_value")), + "dosage_form": str(_value(row, "administration_preset_dosage_form")), + "strength": str(_value(row, "administration_preset_strength")), + "route_original": route_original, + "route_normalized": route, + "preset_revision": _opaque_digest( + key, + "health-medication-administration-preset-v1", + [revision, *revision_values, _value(row, "id"), _value(row, "medikament_name")], + ), + } + + +def planned_event_preset( + row: sqlite3.Row | Mapping[str, Any], key: bytes +) -> dict[str, str] | None: + values = tuple( + str(_value(row, field) or "") + for field in ("planned_quantity_value", "planned_dosage_form", "planned_strength") + ) + if not all(values): + return None + route = str(_value(row, "route_normalized") or "") + route_original = str(_value(row, "route_original") or "") + if route and route not in ROUTES: + return None + return { + "quantity_value": values[0], + "dosage_form": values[1], + "strength": values[2], + "route_original": route_original, + "route_normalized": route, + "preset_revision": _opaque_digest( + key, + "health-medication-planned-administration-preset-v1", + [event_revision(row), event_ref(row, key)], + ), + } + + def _available_fields(connection: sqlite3.Connection, table: str, fields: tuple[str, ...]) -> tuple[str, ...]: actual = {str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")')} return tuple(field for field in fields if field in actual) @@ -293,15 +391,67 @@ def resolve_action_preview( and prescription["prescription_status_provenance"] and prescription["business_revision"] ) - if data["status"] != "corrected" and not trusted_active: + is_v2 = data.get("contract") == ACTION_CONTRACT_VERSION + if is_v2: + if data["mode"] == "planned" and not trusted_active: + raise RuntimeError("planned administration requires an explicitly active prescription") + elif data["status"] != "corrected" and not trusted_active: raise RuntimeError("medication prescription is not explicitly active") planned = resolve_event(connection, data["planned_event_ref"]) if data["planned_event_ref"] else None - target = resolve_event(connection, data["correction_target_ref"]) if data["correction_target_ref"] else None + target_ref = data.get("correction_target_ref", "") + target = resolve_event(connection, target_ref) if target_ref else None + if is_v2: + if data["mode"] == "historical" and planned is not None: + raise RuntimeError("historical administration cannot consume a plan") + if data["mode"] == "planned" and planned is None: + raise RuntimeError("planned administration requires a real plan") if planned is not None and str(planned["event_type"] or "").strip().casefold() != "planned": raise RuntimeError("planned event reference changed") consumed = plan_is_consumed(connection, int(planned["id"])) if planned is not None else False if consumed: raise RuntimeError("planned medication event is already consumed") + if is_v2: + key = public_identity_key(connection) + preset = prescription_preset(prescription, key) + baseline: tuple[str, str, str, str, str] | None = None + expected_revision = "" + if data["mode"] == "planned" and planned is not None: + baseline_values = tuple( + str(_value(planned, field) or "") + for field in ( + "planned_quantity_value", "planned_dosage_form", + "planned_strength", "route_original", "route_normalized", + ) + ) + if not all(baseline_values[:3]): + raise RuntimeError("planned medication event lacks a structured administration preset") + baseline = ( + baseline_values[0], baseline_values[1], + baseline_values[2], baseline_values[3], baseline_values[4], + ) + expected_revision = _opaque_digest( + key, + "health-medication-planned-administration-preset-v1", + [event_revision(planned), event_ref(planned, key)], + ) + elif preset is not None: + baseline = ( + preset["quantity_value"], preset["dosage_form"], + preset["strength"], preset["route_original"], preset["route_normalized"], + ) + expected_revision = preset["preset_revision"] + if baseline is None: + if data["preset_revision"]: + raise RuntimeError("administration preset is no longer available") + else: + if not hmac.compare_digest(expected_revision, data["preset_revision"]): + raise RuntimeError("administration preset changed") + actual = ( + data["quantity_value"], data["dosage_form"], + data["strength"], data["route_original"], data["route_normalized"], + ) + if actual != baseline and not data["deviation_confirmed"]: + raise RuntimeError("administration differs from verified preset") if target is not None and connection.execute( "SELECT 1 FROM medication_administrations WHERE corrects_event_id=? LIMIT 1", (int(target["id"]),), @@ -334,13 +484,22 @@ def _text(value: Any, maximum: int, *, required: bool = False) -> str: if ( (required and not cleaned) or len(cleaned) > maximum - or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\|\.hermes", cleaned, re.I) + or re.search(r"[\x00-\x1f\x7f]|https?://|\\|\.hermes", cleaned, re.I) + or cleaned.startswith("/") + or re.search(r"(?:^|/)\.\.(?:/|$)", cleaned) ): raise ValueError("invalid medication text") return cleaned -def validate_action_data(data: Any) -> dict[str, Any]: +def _field_text(field: str, value: Any, maximum: int, *, required: bool = False) -> str: + try: + return _text(value, maximum, required=required) + except ValueError as error: + raise MedicationFieldError(field) from error + + +def _validate_action_data_v1(data: Any) -> dict[str, Any]: required = { "contract", "status", @@ -366,7 +525,7 @@ def validate_action_data(data: Any) -> dict[str, Any]: "deviation_confirmed", "duplicate_confirmed", } - if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION: + if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION_V1: raise ValueError("invalid medication action shape") status = data["status"] if status not in STATUSES: @@ -425,7 +584,7 @@ def validate_action_data(data: Any) -> dict[str, Any]: elif actual_value or actual_unit or planned_value or planned_unit: raise ValueError("missed or unknown correction cannot assert dose values") return { - "contract": ACTION_CONTRACT_VERSION, + "contract": ACTION_CONTRACT_VERSION_V1, "status": status, "medication_ref": medication, "planned_event_ref": planned, @@ -449,3 +608,76 @@ def validate_action_data(data: Any) -> dict[str, Any]: "deviation_confirmed": data["deviation_confirmed"], "duplicate_confirmed": data["duplicate_confirmed"], } + + +def _validate_action_data_v2(data: Any) -> dict[str, Any]: + required = { + "contract", "mode", "status", "medication_ref", "planned_event_ref", + "name", "quantity_value", "dosage_form", "strength", "route_original", + "route_normalized", "injection_region", "injection_side", "injection_detail", + "note", "preset_revision", "preview_revision", "deviation_confirmed", + "duplicate_confirmed", + } + if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION: + raise ValueError("invalid medication action shape") + if data["mode"] not in {"historical", "planned"}: + raise MedicationFieldError("capture_mode") + if data["status"] != "administered": + raise MedicationFieldError("capture_mode") + medication = data["medication_ref"] + planned = data["planned_event_ref"] + if not isinstance(medication, str) or not OPAQUE_RE.fullmatch(medication) or not medication.startswith("medrx_"): + raise MedicationFieldError("medication") + if planned and (not isinstance(planned, str) or not OPAQUE_RE.fullmatch(planned) or not planned.startswith("medevt_")): + raise MedicationFieldError("capture_mode") + if data["mode"] == "historical" and planned: + raise ValueError("historical administration cannot reference a plan") + if data["mode"] == "planned" and not planned: + raise ValueError("planned administration requires a real plan") + for field in ("preview_revision", "preset_revision"): + value = data[field] + if value and (not isinstance(value, str) or not REVISION_RE.fullmatch(value)): + raise MedicationFieldError("preview" if field == "preview_revision" else "preset") + route = data["route_normalized"] + side = data["injection_side"] + if route not in ROUTES: + raise MedicationFieldError("route_normalized") + if side not in (SIDES | {""}): + raise MedicationFieldError("injection_side") + quantity = _field_text("quantity_value", data["quantity_value"], 40, required=True) + dosage_form = _field_text("dosage_form", data["dosage_form"], 40, required=True) + strength = _field_text("strength", data["strength"], 80, required=True) + injection_region = _field_text("injection_region", data["injection_region"], 80) + injection_detail = _field_text("injection_detail", data["injection_detail"], 120) + if (injection_region or side or injection_detail) and route not in INJECTION_ROUTES: + raise MedicationFieldError("route_normalized") + for flag in ("deviation_confirmed", "duplicate_confirmed"): + if type(data[flag]) is not bool: + raise MedicationFieldError(flag) + return { + "contract": ACTION_CONTRACT_VERSION, + "mode": data["mode"], + "status": "administered", + "medication_ref": medication, + "planned_event_ref": planned, + "name": _field_text("medication", data["name"], 120, required=True), + "quantity_value": quantity, + "dosage_form": dosage_form, + "strength": strength, + "route_original": _field_text("route_original", data["route_original"], 60), + "route_normalized": route, + "injection_region": injection_region, + "injection_side": side, + "injection_detail": injection_detail, + "note": _field_text("note", data["note"], 300), + "preset_revision": data["preset_revision"], + "preview_revision": data["preview_revision"], + "deviation_confirmed": data["deviation_confirmed"], + "duplicate_confirmed": data["duplicate_confirmed"], + } + + +def validate_action_data(data: Any) -> dict[str, Any]: + if isinstance(data, dict) and data.get("contract") == ACTION_CONTRACT_VERSION_V1: + return _validate_action_data_v1(data) + return _validate_action_data_v2(data) diff --git a/scripts/health/dashboard_v5/medication_schema.py b/scripts/health/dashboard_v5/medication_schema.py index 7c04ce0..2e8dd38 100644 --- a/scripts/health/dashboard_v5/medication_schema.py +++ b/scripts/health/dashboard_v5/medication_schema.py @@ -10,14 +10,22 @@ import re import sqlite3 import secrets -MIGRATION_NAME = "sprint7c_f_additive_medication_history" -SCHEMA_VERSION = 7 +MIGRATION_NAME = "sprint7c_f1_medication_capture_hotfix" +SCHEMA_VERSION = 8 PRESCRIPTION_COLUMNS: tuple[tuple[str, str], ...] = ( ("prescription_status", "TEXT"), ("prescription_status_source", "TEXT"), ("prescription_status_provenance", "TEXT"), ("business_revision", "TEXT"), + ("administration_preset_quantity_value", "TEXT"), + ("administration_preset_dosage_form", "TEXT"), + ("administration_preset_strength", "TEXT"), + ("administration_preset_route_original", "TEXT"), + ("administration_preset_route", "TEXT"), + ("administration_preset_source", "TEXT"), + ("administration_preset_provenance", "TEXT"), + ("administration_preset_revision", "TEXT"), ) EVENT_COLUMNS: tuple[tuple[str, str], ...] = ( @@ -37,6 +45,12 @@ EVENT_COLUMNS: tuple[tuple[str, str], ...] = ( ("corrected_target_status", "TEXT"), ("correction_reason", "TEXT"), ("business_revision", "TEXT"), + ("planned_quantity_value", "TEXT"), + ("planned_dosage_form", "TEXT"), + ("planned_strength", "TEXT"), + ("actual_quantity_value", "TEXT"), + ("actual_dosage_form", "TEXT"), + ("actual_strength", "TEXT"), ) ROUTES = ( @@ -53,6 +67,16 @@ EVENT_STATUSES = ("planned", "administered", "missed", "corrected") CORRECTED_TARGET_STATUSES = ("planned", "administered", "missed") PRESCRIPTION_STATUSES = ("active", "ended", "paused", "unknown") INJECTION_SIDES = ("left", "right", "unspecified") +HYRIMOZ_PRESET_NAME = "Hyrimoz / Adalimumab" +HYRIMOZ_PRESET = ( + "1", + "Spritze", + "40 mg/0,4 ml", + "subkutan", + "subcutaneous", + "user_verified", + "sprint7c_f1_explicit_acceptance_contract", +) MANAGED_INDEX_NAMES = { "ux_medication_correction_origin", "ix_medication_event_medication_time", @@ -67,7 +91,7 @@ MANAGED_INDEX_SHA256 = { MANAGED_TRIGGER_SHA256 = { "trg_medication_event_immutable_delete": "ca6ec62519fbde1218cad8e425acf6a07b40205480c9a0abf16d4db92d7c1caa", "trg_medication_event_immutable_update": "3604f96e3eac08cf1675fa75b105c7fcf126e8530b6b57ee81c11e2ec89269fb", - "trg_medication_event_validate_insert": "822efdfca9ca473cf6a70fae585d1e199717de64b207bc62c2ee97cbe6f24b27", + "trg_medication_event_validate_insert": "e9d05b5824d3e3fdb3445ce19d19bbcc7cbc2afeab762ecb70bc30de31e2460a", "trg_medication_plan_correction_consumption_insert": "7e8fbf85f3e419af13df8a94f4c56f940071527d9602aa4a23bf298ba185d4e0", "trg_medication_plan_effective_consumption_insert": "a1284f1ca648da51ecfd6ecca31f75b5b882c598a156bf6fd11d52e29f3fb0f7", "trg_medication_prescription_validate_insert": "42c4d290ea34f3bd4542527d39d817cf6d5c0663577d7ede8d5667a6bdd18ef9", @@ -106,6 +130,53 @@ def _add_columns( return changes +def _install_verified_presets(connection: sqlite3.Connection) -> list[str]: + """Install only the exact preset explicitly verified for this hotfix.""" + rows = connection.execute( + """SELECT id,anwendungsform, + administration_preset_quantity_value, + administration_preset_dosage_form, + administration_preset_strength, + administration_preset_route_original, + administration_preset_route, + administration_preset_source, + administration_preset_provenance, + administration_preset_revision + FROM medikamente WHERE medikament_name=? ORDER BY id""", + (HYRIMOZ_PRESET_NAME,), + ).fetchall() + if not rows: + return [] + if len(rows) != 1: + raise RuntimeError("verified Hyrimoz preset identity is ambiguous") + row = rows[0] + if str(row[1] or "").strip().casefold() != "subkutan": + raise RuntimeError("verified Hyrimoz route source changed") + revision = hashlib.sha256( + (HYRIMOZ_PRESET_NAME + "\0" + "\0".join(HYRIMOZ_PRESET)).encode("utf-8") + ).hexdigest() + expected = (*HYRIMOZ_PRESET, revision) + current = tuple(row[index] for index in range(2, 10)) + if all(value is None for value in current): + connection.execute( + """UPDATE medikamente SET + administration_preset_quantity_value=?, + administration_preset_dosage_form=?, + administration_preset_strength=?, + administration_preset_route_original=?, + administration_preset_route=?, + administration_preset_source=?, + administration_preset_provenance=?, + administration_preset_revision=? + WHERE id=?""", + (*expected, int(row[0])), + ) + return ["medikamente.verified_hyrimoz_administration_preset"] + if current != expected: + raise RuntimeError("verified Hyrimoz preset conflicts with existing data") + return [] + + def _has_legacy_event_uniqueness(connection: sqlite3.Connection) -> bool: for row in connection.execute("PRAGMA index_list('medication_administrations')"): if not int(row[2]): @@ -223,6 +294,7 @@ def _apply_schema_uncommitted(connection: sqlite3.Connection) -> list[str]: changes.append("medication_administrations.relax_legacy_tuple_uniqueness") changes.extend(_add_columns(connection, "medikamente", PRESCRIPTION_COLUMNS)) changes.extend(_add_columns(connection, "medication_administrations", EVENT_COLUMNS)) + changes.extend(_install_verified_presets(connection)) _execute_sql_script( connection, f""" @@ -310,6 +382,23 @@ def _apply_schema_uncommitted(connection: sqlite3.Connection) -> list[str]: AND lower(trim(COALESCE(p.event_type,'')))='planned' AND (p.medication_id=NEW.medication_id OR (p.medication_id IS NULL AND p.medication_name=NEW.medication_name)) ) THEN RAISE(ABORT,'invalid planned event relation') END; + SELECT CASE WHEN + ((NEW.actual_quantity_value IS NOT NULL)+(NEW.actual_dosage_form IS NOT NULL)+(NEW.actual_strength IS NOT NULL)) NOT IN (0,3) + OR ((NEW.planned_quantity_value IS NOT NULL)+(NEW.planned_dosage_form IS NOT NULL)+(NEW.planned_strength IS NOT NULL)) NOT IN (0,3) + THEN RAISE(ABORT,'incomplete structured medication fields') END; + SELECT CASE WHEN NEW.source='dashboard_medication_action_version_two' AND ( + trim(COALESCE(NEW.actual_quantity_value,''))='' OR + trim(COALESCE(NEW.actual_dosage_form,''))='' OR + trim(COALESCE(NEW.actual_strength,''))='' OR + (NEW.planned_event_id IS NOT NULL AND ( + trim(COALESCE(NEW.planned_quantity_value,''))='' OR + trim(COALESCE(NEW.planned_dosage_form,''))='' OR + trim(COALESCE(NEW.planned_strength,''))='' + )) OR + (NEW.planned_event_id IS NULL AND ( + NEW.planned_quantity_value IS NOT NULL OR NEW.planned_dosage_form IS NOT NULL OR NEW.planned_strength IS NOT NULL + )) + ) THEN RAISE(ABORT,'invalid version two structured medication fields') END; SELECT CASE WHEN lower(trim(COALESCE(NEW.event_type,'')))='corrected' AND ( NEW.corrects_event_id IS NULL OR NEW.corrected_target_status NOT IN ('planned','administered','missed') OR diff --git a/scripts/health/health_dashboard_action_worker.py b/scripts/health/health_dashboard_action_worker.py index 5efcc3a..a77eb66 100644 --- a/scripts/health/health_dashboard_action_worker.py +++ b/scripts/health/health_dashboard_action_worker.py @@ -35,7 +35,7 @@ try: from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment @@ -92,7 +92,7 @@ except ModuleNotFoundError: # direct importlib fixture execution from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment @@ -1043,7 +1043,7 @@ def apply_capture_action(payload: dict[str, Any]) -> str: str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") } - if payload["capture_type"] == "medication" and data.get("contract") == MEDICATION_ACTION_CONTRACT: + if payload["capture_type"] == "medication" and data.get("contract") in MEDICATION_ACTION_CONTRACTS: assert_medication_schema(connection) expected_revision, prescription, planned, correction_target = resolve_action_preview( connection, payload @@ -1051,28 +1051,47 @@ def apply_capture_action(payload: dict[str, Any]) -> str: if not hmac.compare_digest(expected_revision, data["preview_revision"]): raise RuntimeError("stale medication preview revision") medication_id = int(prescription["id"]) - duplicate = connection.execute( - """SELECT 1 FROM medication_administrations - WHERE business_revision IS NOT NULL AND medication_id=? - AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) - AND COALESCE(occurred_at,'')=? - AND lower(trim(COALESCE(event_type,'')))=? - AND COALESCE(actual_dose_value,'')=? - AND COALESCE(actual_dose_unit,'')=? LIMIT 1""", - ( - medication_id, - int(planned["id"]) if planned is not None else None, - payload["occurred_at"], - data["status"], - data["actual_dose_value"], - data["actual_dose_unit"], - ), - ).fetchone() + if data["contract"] == "health.medication_action.v2": + duplicate = connection.execute( + """SELECT 1 FROM medication_administrations + WHERE business_revision IS NOT NULL AND medication_id=? + AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) + AND COALESCE(occurred_at,'')=? + AND lower(trim(COALESCE(event_type,'')))=? + AND COALESCE(actual_quantity_value,'')=? + AND COALESCE(actual_dosage_form,'')=? + AND COALESCE(actual_strength,'')=? LIMIT 1""", + ( + medication_id, + int(planned["id"]) if planned is not None else None, + payload["occurred_at"], data["status"], + data["quantity_value"], data["dosage_form"], data["strength"], + ), + ).fetchone() + else: + duplicate = connection.execute( + """SELECT 1 FROM medication_administrations + WHERE business_revision IS NOT NULL AND medication_id=? + AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) + AND COALESCE(occurred_at,'')=? + AND lower(trim(COALESCE(event_type,'')))=? + AND COALESCE(actual_dose_value,'')=? + AND COALESCE(actual_dose_unit,'')=? LIMIT 1""", + ( + medication_id, + int(planned["id"]) if planned is not None else None, + payload["occurred_at"], data["status"], + data["actual_dose_value"], data["actual_dose_unit"], + ), + ).fetchone() if duplicate and not data["duplicate_confirmed"]: raise RuntimeError("possible duplicate medication event") medication_context = { "medication_id": medication_id, "planned_event_id": int(planned["id"]) if planned is not None else None, + "planned_quantity_value": str(planned["planned_quantity_value"] or "") if planned is not None else "", + "planned_dosage_form": str(planned["planned_dosage_form"] or "") if planned is not None else "", + "planned_strength": str(planned["planned_strength"] or "") if planned is not None else "", "corrects_event_id": int(correction_target["id"]) if correction_target is not None else None, } elif payload["capture_type"] == "medication" and "medication_administrations" in known_tables: @@ -1093,7 +1112,11 @@ def apply_capture_action(payload: dict[str, Any]) -> str: } if data["name"] not in known_supplements: raise RuntimeError("supplement is not in the known exact plan") - if payload["capture_type"] in {"medication", "supplement"} and data["status"] == "administered": + if ( + payload["capture_type"] in {"medication", "supplement"} + and data["status"] == "administered" + and data.get("contract") != "health.medication_action.v2" + ): if not (data["plan_value_confirmed"] or data["deviation_confirmed"]): raise RuntimeError("planned administration requires conscious confirmation") if payload["capture_type"] == "measurement": @@ -1223,44 +1246,57 @@ def apply_capture_action(payload: dict[str, Any]) -> str: ) elif payload["capture_type"] == "medication" and "medication_administrations" in available_tables: if medication_context is not None: - actual_dose = " ".join( - part for part in (data["actual_dose_value"], data["actual_dose_unit"]) if part - ) - legacy_route = data["route_original"] or data["route_normalized"] - connection.execute( - """INSERT INTO medication_administrations( - datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, - medication_id,planned_event_id,planned_dose_value,planned_dose_unit,actual_dose_value, - actual_dose_unit,route_original,route_normalized,injection_region,injection_side, - injection_detail,lot_number,corrects_event_id,corrected_target_status,correction_reason, - business_revision) VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - day, - data["name"], - actual_dose or None, - legacy_route or None, - data["status"], - data["note"] or None, - "dashboard_v5_medication_action", - payload["occurred_at"], - medication_context["medication_id"], - medication_context["planned_event_id"], - data["planned_dose_value"] or None, - data["planned_dose_unit"] or None, - data["actual_dose_value"] or None, - data["actual_dose_unit"] or None, - data["route_original"] or None, - data["route_normalized"] or None, - data["injection_region"] or None, - data["injection_side"] or None, - data["injection_detail"] or None, - data["lot_number"] or None, - medication_context["corrects_event_id"], - data["corrected_target_status"] or None, - data["correction_reason"] or None, - action_hash, - ), - ) + if data["contract"] == "health.medication_action.v2": + legacy_dose = f"{data['quantity_value']} {data['dosage_form']} · {data['strength']}" + legacy_route = data["route_original"] or data["route_normalized"] + connection.execute( + """INSERT INTO medication_administrations( + datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, + medication_id,planned_event_id,planned_quantity_value,planned_dosage_form, + planned_strength,route_original,route_normalized,injection_region, + injection_side,injection_detail,business_revision,actual_quantity_value, + actual_dosage_form,actual_strength) + VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + day, data["name"], legacy_dose, legacy_route or None, + data["status"], data["note"] or None, + "dashboard_medication_action_version_two", payload["occurred_at"], + medication_context["medication_id"], medication_context["planned_event_id"], + medication_context["planned_quantity_value"] or None, + medication_context["planned_dosage_form"] or None, + medication_context["planned_strength"] or None, + data["route_original"] or None, data["route_normalized"] or None, + data["injection_region"] or None, data["injection_side"] or None, + data["injection_detail"] or None, action_hash, + data["quantity_value"], data["dosage_form"], data["strength"], + ), + ) + else: + actual_dose = " ".join( + part for part in (data["actual_dose_value"], data["actual_dose_unit"]) if part + ) + legacy_route = data["route_original"] or data["route_normalized"] + connection.execute( + """INSERT INTO medication_administrations( + datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, + medication_id,planned_event_id,planned_dose_value,planned_dose_unit,actual_dose_value, + actual_dose_unit,route_original,route_normalized,injection_region,injection_side, + injection_detail,lot_number,corrects_event_id,corrected_target_status,correction_reason, + business_revision) VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + day, data["name"], actual_dose or None, legacy_route or None, + data["status"], data["note"] or None, + "dashboard_v5_medication_action", payload["occurred_at"], + medication_context["medication_id"], medication_context["planned_event_id"], + data["planned_dose_value"] or None, data["planned_dose_unit"] or None, + data["actual_dose_value"] or None, data["actual_dose_unit"] or None, + data["route_original"] or None, data["route_normalized"] or None, + data["injection_region"] or None, data["injection_side"] or None, + data["injection_detail"] or None, data["lot_number"] or None, + medication_context["corrects_event_id"], data["corrected_target_status"] or None, + data["correction_reason"] or None, action_hash, + ), + ) else: dose = " ".join( str(part) @@ -2321,11 +2357,12 @@ def write_action_receipt(action_id: str, action: str) -> None: pass -def write_capture_receipt(idempotency_key: str, status: str) -> None: - if not re.fullmatch(r"[0-9a-f]{32}", idempotency_key) or status not in { - "processed", - "rejected", - }: +def write_capture_receipt(idempotency_key: str, status: str, action_hash: str) -> None: + if ( + not re.fullmatch(r"[0-9a-f]{32}", idempotency_key) + or status not in {"processed", "rejected"} + or not re.fullmatch(r"[0-9a-f]{64}", action_hash) + ): raise ValueError("invalid capture receipt") receipt_dir = ACTION_INBOX / "capture-receipts" if receipt_dir.exists() and receipt_dir.is_symlink(): @@ -2335,8 +2372,9 @@ def write_capture_receipt(idempotency_key: str, status: str) -> None: target = receipt_dir / f"{idempotency_key}.json" temp = receipt_dir / f".{idempotency_key}.{os.getpid()}.tmp" record = { - "version": 1, + "version": 2, "status": status, + "action_hash": action_hash, "processed_at": datetime.now(LOCAL_TIMEZONE).isoformat(timespec="seconds"), } descriptor = os.open( @@ -2368,22 +2406,26 @@ def main() -> int: failures = 0 for path in sorted(ACTION_INBOX.glob("*.json")): payload: dict[str, Any] | None = None + receipt_action_hash: str | None = None try: payload = load_action(path) + if payload.get("action") == "capture_entry": + payload = validate_capture_payload(payload) + receipt_action_hash = hashlib.sha256(_canonical_json(payload).encode()).hexdigest() if payload.get("action") == "symptom_checkin": apply_action(payload["date"], payload["scores"], payload["notes"]) else: apply_action(payload, path.stem) if payload.get("action") in {"nutrition_mapping", "symptom_checkin"}: write_action_receipt(path.stem, str(payload["action"])) - if payload.get("action") == "capture_entry": - write_capture_receipt(payload["idempotency_key"], "processed") + if payload.get("action") == "capture_entry" and receipt_action_hash is not None: + write_capture_receipt(payload["idempotency_key"], "processed", receipt_action_hash) path.unlink() except Exception: failures += 1 - if payload and payload.get("action") == "capture_entry": + if payload and payload.get("action") == "capture_entry" and receipt_action_hash is not None: try: - write_capture_receipt(payload["idempotency_key"], "rejected") + write_capture_receipt(payload["idempotency_key"], "rejected", receipt_action_hash) except Exception: pass try: diff --git a/scripts/health/health_dashboard_server.py b/scripts/health/health_dashboard_server.py index 58e878b..e849486 100644 --- a/scripts/health/health_dashboard_server.py +++ b/scripts/health/health_dashboard_server.py @@ -6,6 +6,7 @@ from __future__ import annotations import base64 import binascii import hashlib +import hmac import json import mimetypes import os @@ -35,7 +36,7 @@ from dashboard_v5.observation_contract import validate_action as validate_observ from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import MAX_ORIGINAL_BYTES as MAX_CAPTURE_MEDIA_BYTES, quarantine_bytes @@ -980,9 +981,24 @@ def validate_mapping_submission(form: dict[str, list[str]]) -> tuple[dict[str, o }, return_to -def capture_processing_status(idempotency_key: str) -> str: - if not re.fullmatch(r"[0-9a-f]{32}", idempotency_key): - raise ValueError("invalid status key") +def capture_payload_action_hash(payload: dict[str, Any]) -> str: + canonical = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def capture_status_token(idempotency_key: str, action_hash: str) -> str: + if not re.fullmatch(r"[0-9a-f]{32}", idempotency_key) or not re.fullmatch(r"[0-9a-f]{64}", action_hash): + raise ValueError("invalid capture status binding") + return hmac.new( + load_api_token().encode("utf-8"), + f"health-capture-status-v1\0{idempotency_key}\0{action_hash}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def capture_processing_status(idempotency_key: str, supplied_token: str) -> str: + if not re.fullmatch(r"[0-9a-f]{32}", idempotency_key) or not re.fullmatch(r"[0-9a-f]{64}", supplied_token): + raise ValueError("invalid status binding") path = ACTION_INBOX / "capture-receipts" / f"{idempotency_key}.json" try: descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) @@ -1002,8 +1018,12 @@ def capture_processing_status(idempotency_key: str) -> str: os.close(descriptor) record = json.loads(data.decode()) status = record.get("status") - if status not in {"processed", "rejected"}: + action_hash = record.get("action_hash") + if record.get("version") != 2 or status not in {"processed", "rejected"} or not isinstance(action_hash, str): raise OSError("invalid capture receipt") + expected_token = capture_status_token(idempotency_key, action_hash) + if not hmac.compare_digest(expected_token, supplied_token): + raise OSError("capture receipt binding mismatch") return status def open_capture_media( @@ -1193,6 +1213,54 @@ def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> str: ) +def _medication_preview_error(error: Exception) -> tuple[int, dict[str, Any]]: + field = getattr(error, "field", None) + allowed_fields = { + "quantity_value", "dosage_form", "strength", "route_original", + "injection_region", "injection_side", "injection_detail", "note", "medication", + "route_normalized", "capture_mode", "deviation_confirmed", + "duplicate_confirmed", "preset", "preview", + } + if isinstance(error, ValueError) and field in allowed_fields: + messages = { + "quantity_value": "Bitte eine gültige Menge eingeben.", + "dosage_form": "Bitte eine gültige Darreichungsform eingeben.", + "strength": "Bitte eine gültige Wirkstoffstärke eingeben.", + "route_original": "Bitte den dokumentierten Applikationsweg prüfen.", + "injection_region": "Bitte die Injektionsregion prüfen.", + "injection_side": "Bitte die Seite der Injektionsstelle prüfen.", + "injection_detail": "Bitte die Injektionsstelle prüfen.", + "route_normalized": "Bitte den Applikationsweg prüfen.", + "capture_mode": "Bitte den Erfassungsmodus oder Plantermin prüfen.", + "deviation_confirmed": "Bitte die Abweichung bewusst bestätigen.", + "duplicate_confirmed": "Bitte einen möglichen Doppeleintrag bewusst bestätigen.", + "preset": "Die Vorauswahl ist nicht mehr aktuell. Bitte neu laden.", + "preview": "Die Vorschau ist nicht mehr aktuell. Bitte erneut prüfen.", + "note": "Bitte die Notiz prüfen.", + "medication": "Bitte das ausgewählte Medikament prüfen.", + } + return 422, {"error": {"kind": "validation", "code": f"{field}_invalid", "field": field, "message": messages.get(field, "Bitte die markierte Angabe prüfen.")}} + reason = str(error) + rules = ( + ("invalid medication action shape", 422, "payload_invalid", "medication", "Die Medikationsangaben sind unvollständig oder ungültig."), + ("invalid medication text", 422, "medication_text_invalid", "medication", "Name, Stärke oder Notiz enthält eine unzulässige Angabe."), + ("requires a real plan", 422, "planned_event_required", "capture_mode", "Für diesen Modus ist kein belastbarer Plantermin verknüpft."), + ("lacks a structured administration preset", 422, "planned_event_unstructured", "capture_mode", "Der Plantermin enthält keine revisionssichere Mengenangabe."), + ("differs from verified preset", 422, "preset_deviation_unconfirmed", "deviation_confirmed", "Die Eingabe weicht von der verifizierten Vorauswahl ab und muss bewusst bestätigt werden."), + ("administration preset changed", 409, "preset_changed", "preset", "Die verifizierte Vorauswahl hat sich geändert. Bitte Vorschau neu laden."), + ("administration preset is no longer available", 409, "preset_unavailable", "preset", "Die verifizierte Vorauswahl ist nicht mehr verfügbar. Bitte neu laden."), + ("stale medication preview", 409, "preview_changed", "preview", "Die Vorschau ist nicht mehr aktuell. Bitte erneut prüfen."), + ("already consumed", 409, "planned_event_consumed", "capture_mode", "Der Plantermin wurde bereits dokumentiert."), + ("explicitly active prescription", 422, "prescription_not_active_for_plan", "capture_mode", "Ein Plantermin kann nur gegen eine ausdrücklich aktive Verordnung dokumentiert werden."), + ) + for fragment, status_code, code, field, message in rules: + if fragment in reason: + return status_code, {"error": {"kind": "validation", "code": code, "field": field, "message": message}} + if isinstance(error, ValueError): + return 422, {"error": {"kind": "validation", "code": "medication_validation_failed", "field": "medication", "message": "Die Medikationsangaben sind ungültig. Bitte die Felder prüfen."}} + return 503, {"error": {"kind": "technical", "code": "medication_preview_unavailable", "field": "", "message": "Die Erfassung ist technisch nicht verfügbar. Es wurde nichts vorgemerkt."}} + + class Handler(BaseHTTPRequestHandler): def send_error( self, @@ -1356,7 +1424,7 @@ class Handler(BaseHTTPRequestHandler): if ( payload["capture_type"] != "medication" or not isinstance(preview_data, dict) - or preview_data.get("contract") != MEDICATION_ACTION_CONTRACT + or preview_data.get("contract") not in MEDICATION_ACTION_CONTRACTS ): raise ValueError("invalid medication preview contract") preview_connection = connect_read_only(API_DB) @@ -1365,8 +1433,6 @@ class Handler(BaseHTTPRequestHandler): medication_preview_revision = resolve_action_preview( preview_connection, payload )[0] - except RuntimeError as error: - raise ValueError("medication preview context unavailable") from error finally: preview_connection.close() return_to = "v5" @@ -1395,8 +1461,12 @@ class Handler(BaseHTTPRequestHandler): else: self.send_error(409) return - except ValueError: - self.send_error(400) + except (ValueError, RuntimeError, sqlite3.Error) as error: + if action_path == MEDICATION_PREVIEW_ROUTE: + status_code, body = _medication_preview_error(error) + self._send_api_json(status_code, body, True) + else: + self.send_error(400) return except QueueFullError: self.send_error(503) @@ -1411,6 +1481,16 @@ class Handler(BaseHTTPRequestHandler): True, ) return + if action_path == CAPTURE_ROUTE and "application/json" in self.headers.get("Accept", ""): + action_hash = capture_payload_action_hash(payload) + status_key = str(payload["idempotency_key"]) + status_token = capture_status_token(status_key, action_hash) + self._send_api_json( + 202, + {"status": "queued", "status_key": status_key, "status_token": status_token}, + True, + ) + return if action_path in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE} and "application/json" in self.headers.get( "Accept", "" ): @@ -1804,13 +1884,13 @@ class Handler(BaseHTTPRequestHandler): if send_body: self.wfile.write(data) return - capture_status = re.fullmatch(r"/api/v1/capture/status/([a-f0-9]{32})", path) + capture_status = re.fullmatch(r"/api/v1/capture/status/([a-f0-9]{32})/([a-f0-9]{64})", path) if capture_status: if query: self._send_api_error(400, "unknown_parameter", send_body) return try: - status = capture_processing_status(capture_status.group(1)) + status = capture_processing_status(capture_status.group(1), capture_status.group(2)) except (OSError, ValueError, UnicodeError, json.JSONDecodeError): self._send_api_error(503, "status_unavailable", send_body) return __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__