diff --git a/scripts/health/assets/health-assets/dashboard-v5-record.js b/scripts/health/assets/health-assets/dashboard-v5-record.js index 9482935..4ab3635 100644 --- a/scripts/health/assets/health-assets/dashboard-v5-record.js +++ b/scripts/health/assets/health-assets/dashboard-v5-record.js @@ -20,7 +20,7 @@ const FILTER_KEYS = { overview: [], labs: ['q', 'from', 'to', 'status', 'source'], - medications: ['from', 'to'], + medications: ['from', 'to', 'medication', 'status', 'source'], appointments: ['from', 'to', 'order', 'institution'], documents: ['q', 'from', 'to', 'category', 'institution', 'type', 'review_status', 'original_status', 'extraction_status', 'search_status', 'queue', 'sort', 'cursor', 'limit'], report: ['from', 'to', 'sections', 'charts'], @@ -44,7 +44,11 @@ } function snapshotFilters() { - return Object.fromEntries([...allowedTabs].map(tab => [tab, sanitizeFilters(tab, filtersByTab[tab])])); + return Object.fromEntries([...allowedTabs].map(tab => { + const clean = sanitizeFilters(tab, filtersByTab[tab]); + if (tab === 'medications') delete clean.medication; + return [tab, clean]; + })); } function restoreFilters(state) { @@ -165,7 +169,8 @@ if (params.get('view') !== 'record') return null; const tab = params.get('tab') || 'overview'; if (!allowedTabs.has(tab)) return null; - const allowedForTab = new Set(['view', 'tab', 'document', 'preview', 'queued', ...(FILTER_KEYS[tab] || [])]); + const urlFilterKeys = (FILTER_KEYS[tab] || []).filter(key => !(tab === 'medications' && key === 'medication')); + const allowedForTab = new Set(['view', 'tab', 'document', 'preview', 'queued', ...urlFilterKeys]); if ([...params.keys()].some(key => !allowedForTab.has(key))) return null; const documentId = params.get('document'); if (documentId && !/^api-document-[a-f0-9]{24}$/.test(documentId)) return null; @@ -174,7 +179,7 @@ const queued=params.get('queued'); if(queued&&!/^[A-Za-z0-9_-]{1,64}$/.test(queued))return null; const urlFilters = {}; - (FILTER_KEYS[tab] || []).forEach(key => { if (params.has(key)) urlFilters[key] = params.get(key); }); + urlFilterKeys.forEach(key => { if (params.has(key)) urlFilters[key] = params.get(key); }); return { tab, documentId, preview: preview || null,queued:queued||null, filters:sanitizeFilters(tab,urlFilters) }; } @@ -187,7 +192,10 @@ if (options.preview) url.searchParams.set('preview', options.preview); if (options.filters) { filtersByTab[tab] = sanitizeFilters(tab, options.filters); - Object.entries(filtersByTab[tab]).forEach(([key,value]) => { if (typeof value === 'string' && value) url.searchParams.set(key,value); }); + Object.entries(filtersByTab[tab]).forEach(([key,value]) => { + if (tab === 'medications' && key === 'medication') return; + if (typeof value === 'string' && value) url.searchParams.set(key,value); + }); } const state = { record: { tab, documentId: options.documentId || null, preview: options.preview || null, filtersByTab: snapshotFilters() } }; if (options.replace) history.replaceState(state, '', url); @@ -729,16 +737,95 @@ return node; } + 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(' '); + 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') { + if(item.effective_status==='administered') return actual || 'Korrigierte tatsächliche Dosis nicht dokumentiert'; + if(item.effective_status==='planned') return planned || 'Korrigierte geplante Dosis nicht dokumentiert'; + return 'Korrigierter Zielstatus: ausgelassen'; + } + return item.legacy_dose ? `Legacy-Freitext (nicht strukturiert): ${item.legacy_dose}` : 'Dosis nicht dokumentiert'; + }; + + function medicationActionDialog(prescription, eventItem = null, mode = 'administered') { + const dialog = document.createElement('dialog'); dialog.className = 'medication-action-dialog'; + const form = document.createElement('form'); form.method = 'dialog'; + const heading = ({administered:'Einnahme oder Verabreichung dokumentieren',missed:'Auslassung dokumentieren',corrected:'Eintrag korrigieren'})[mode]; + form.append(element('h2', heading), element('p', 'Dokumentation ohne medizinische Bewertung oder Änderung der Verordnung.', 'v5-meta')); + const when = document.createElement('input'); when.type='datetime-local'; when.required=true; when.value=document.querySelector("#medication-event-dialog input[name='occurred_at']")?.value || ''; + const plannedValue=document.createElement('input'); plannedValue.maxLength=40; plannedValue.value=eventItem?.planned_dose_value||''; + const plannedUnit=document.createElement('input'); plannedUnit.maxLength=30; plannedUnit.value=eventItem?.planned_dose_unit||''; + const actualValue=document.createElement('input'); actualValue.maxLength=40; actualValue.value=eventItem?.actual_dose_value||''; actualValue.required=mode==='administered'; + const actualUnit=document.createElement('input'); actualUnit.maxLength=30; actualUnit.value=eventItem?.actual_dose_unit||''; actualUnit.required=mode==='administered'; + const routeOriginal=document.createElement('input'); routeOriginal.maxLength=60; routeOriginal.value=eventItem?.route_original||''; + const route=document.createElement('select'); [['unknown','Nicht angegeben'],['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;option.selected=(eventItem?.route_normalized||'unknown')===value;route.append(option);}); + 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 lot=document.createElement('input');lot.maxLength=80;const note=document.createElement('textarea');note.maxLength=300; + const correctionStatus=document.createElement('select');[['planned','Geplant'],['administered','Verabreicht / eingenommen'],['missed','Ausgelassen'],['unknown','Unbekannt']].forEach(([value,label])=>{const option=element('option',label);option.value=value;correctionStatus.append(option);});if(mode==='corrected'&&['planned','administered','missed','unknown'].includes(eventItem?.effective_status))correctionStatus.value=eventItem.effective_status;const correctionReason=document.createElement('textarea');correctionReason.maxLength=300;correctionReason.required=mode==='corrected'; + const planConfirmed=document.createElement('input');planConfirmed.type='checkbox';const deviationConfirmed=document.createElement('input');deviationConfirmed.type='checkbox';const duplicateConfirmed=document.createElement('input');duplicateConfirmed.type='checkbox'; + form.append(formRow('Medikament', element('strong', prescription.name)), formRow('Zeitpunkt', when)); + if(mode==='corrected') form.append(formRow('Korrigierte geplante Dosis',plannedValue),formRow('Korrigierte geplante Einheit',plannedUnit)); + if(mode!=='missed') form.append(formRow('Tatsächlich dokumentierte Dosis', actualValue),formRow('Einheit',actualUnit),formRow('Originalbezeichnung Applikationsweg',routeOriginal),formRow('Normalisierte Anzeige',route)); + if(mode==='corrected') form.append(formRow('Korrigierter Zielstatus',correctionStatus),formRow('Korrekturbegründung',correctionReason)); + if(mode==='administered') form.append(formRow('Geplante Angabe bewusst bestätigt',planConfirmed),formRow('Abweichende tatsächliche Angabe bewusst bestätigt',deviationConfirmed)); + const extra=document.createElement('details');extra.append(element('summary','Weitere Angaben'));const extraBody=element('div',undefined,'medication-extra-fields');extraBody.append(formRow('Injektionsregion',region),formRow('Seite',side),formRow('Ergänzung zur Injektionsstelle',injectionDetail),formRow('Charge',lot),formRow('Notiz',note),formRow('Möglichen identischen Doppeleintrag nach Prüfung zulassen',duplicateConfirmed));extra.append(extraBody);form.append(extra); + 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='';});};route.addEventListener('change',syncInjection);syncInjection(); + const syncCorrectionDose=()=>{if(mode!=='corrected')return;const target=correctionStatus.value;const planned=target==='planned';const administered=target==='administered';plannedValue.disabled=!planned;plannedUnit.disabled=!planned;actualValue.disabled=!administered;actualUnit.disabled=!administered;plannedValue.required=planned;plannedUnit.required=planned;actualValue.required=administered;actualUnit.required=administered;if(!planned){plannedValue.value='';plannedUnit.value='';}if(!administered){actualValue.value='';actualUnit.value='';}};correctionStatus.addEventListener('change',syncCorrectionDose);syncCorrectionDose(); + let frozen=null;let frozenCsrf='';form.addEventListener('input',()=>{if(frozen){frozen=null;frozenCsrf='';preview.hidden=true;submit.textContent='Vorschau prüfen';}}); + form.addEventListener('submit',async event=>{event.preventDefault();if(!frozen){ + if(mode==='administered'&&!planConfirmed.checked&&!deviationConfirmed.checked){status.textContent='Bitte geplante oder abweichende tatsächliche Angabe bewusst bestätigen.';return;} + const revision=mode==='corrected'?eventItem?.correction_preview_revision:(eventItem?.administration_preview_revision||prescription.preview_revision); + if(!revision){status.textContent='Die unveränderliche Vorschau konnte nicht sicher gebunden werden.';return;} + const data={contract:'health.medication_action.v1',status:mode,medication_ref:prescription.id,planned_event_ref:mode!=='corrected'&&eventItem?.status==='planned'?eventItem.id:'',name:prescription.name,planned_dose_value:mode==='corrected'?plannedValue.value.trim():(mode==='missed'?'':(eventItem?.planned_dose_value||'')),planned_dose_unit:mode==='corrected'?plannedUnit.value.trim():(mode==='missed'?'':(eventItem?.planned_dose_unit||'')),actual_dose_value:mode==='missed'?'':actualValue.value.trim(),actual_dose_unit:mode==='missed'?'':actualUnit.value.trim(),route_original:mode==='missed'?'':routeOriginal.value.trim(),route_normalized:mode==='missed'?'unknown':route.value,injection_region:region.value.trim(),injection_side:side.value,injection_detail:injectionDetail.value.trim(),lot_number:lot.value.trim(),correction_target_ref:mode==='corrected'?eventItem.id:'',corrected_target_status:mode==='corrected'?correctionStatus.value:'',correction_reason:mode==='corrected'?correctionReason.value.trim():'',note:note.value.trim(),preview_revision:revision,plan_value_confirmed:planConfirmed.checked,deviation_confirmed:deviationConfirmed.checked,duplicate_confirmed:duplicateConfirmed.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(`capture_csrf_${csrfResponse.status}`); + const csrfResult=await csrfResponse.json(); + const captureCsrf=String(csrfResult.csrf_token||''); + if(!/^[A-Za-z0-9_-]{24,128}$/.test(captureCsrf)) throw new Error('capture_csrf_invalid'); + 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()}); + if(!response.ok) throw new Error(`preview_${response.status}`); + const result=await response.json(); + if(!/^[a-f0-9]{64}$/.test(String(result.preview_revision||''))) throw new Error('preview_invalid'); + draft.data.preview_revision=result.preview_revision; + frozen=draft; + frozenCsrf=captureCsrf; + } catch(_error) { + status.textContent='Die Vorschau konnte nicht sicher serverseitig gebunden werden. Es wurde nichts vorgemerkt.'; + return; + } finally { + submit.disabled=false; + } + preview.replaceChildren(element('h3','Unveränderliche Vorschau'),element('p',`${when.value} · ${prescription.name} · ${mode==='administered'?'Verabreicht / eingenommen':mode==='missed'?'Ausgelassen':`Korrigiert zu ${correctionStatus.options[correctionStatus.selectedIndex].text}`}`),element('p',`Geplant: ${eventItem?medicationDose(eventItem):(prescription.documented_dose||'nicht strukturiert dokumentiert')} · Tatsächlich: ${actualValue.value||'nicht angegeben'} ${actualUnit.value}`,'v5-meta'),element('p',mode==='corrected'?`Begründung: ${correctionReason.value}`:'Keine Änderung der Verordnung.','v5-meta'));preview.hidden=false;submit.textContent='Verbindlich vormerken';return; + } + const post=document.createElement('form');post.method='post';post.action='/health-actions/capture';post.hidden=true;const fields={csrf_token:frozenCsrf,return_to:'v5',payload:JSON.stringify(frozen)};Object.entries(fields).forEach(([name,value])=>{const input=document.createElement('input');input.type='hidden';input.name=name;input.value=value;post.append(input);});document.body.append(post);post.submit(); + }); + dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();when.focus(); + } + async function renderMedications(filters = {}, current) { const node = card('Medikamente'); - const data = await request(`/api/v1/medications?${query(filters)}`, current); - ['planned', 'administered', 'missed', 'corrected'].forEach(kind => { - const section = element('section'); section.append(element('h4', ({ planned: 'Geplant', administered: 'Tatsächlich verabreicht', missed: 'Ausgelassen', corrected: 'Korrigiert' })[kind])); + 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 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] || []; - section.append(rows.length ? table(['Datum', 'Medikament', 'Dosis', 'Quelle'], rows.map(item => [item.date, item.name, item.dose, sourceLabel(item.source)])) : element('p', 'Keine dokumentierten Angaben.')); - node.append(section); + 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); }); - node.append(element('p', `Datenvollständigkeit: ${data.truncated ? `gekürzt (${data.truncated_sections.join(', ')})` : 'vollständige Seite'}`, 'v5-meta')); + 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; } @@ -793,7 +880,7 @@ const personalDraws=[]; if(data.selected_sections.includes('overview')){const part=section('Datenabdeckung und fehlende Bereiche');const rows=Object.entries(data.completeness||{}).map(([key,item])=>[sectionNames[key]||key,item.status==='documented'?'Daten dokumentiert':'Nicht dokumentiert',item.truncated?'Aus Sicherheitsgründen gekürzt':'Vollständige begrenzte Antwort']);part.append(table(['Bereich','Abdeckung','Umfang'],rows));} if(data.selected_sections.includes('symptoms')){const part=section('Beschwerden und Symptomereignisse');const rows=[...(data.additional_symptoms||[]).map(item=>[item.occurred_at||item.date,item.symptom,item.severity??'unbekannt',item.note||'']),...(data.symptoms||[]).map(item=>[item.date,'7-Dimensionen-Tageswert',item.symptoms?.total??'unvollständig',''])];rows.length?part.append(table(['Zeitpunkt','Beschwerde','Schweregrad','Notiz'],rows)):empty(part);} - if(data.selected_sections.includes('medications')){const part=section('Medikamentenverlauf');[['Tatsächlich verabreicht','administered'],['Ausgelassen','missed'],['Korrigiert','corrected'],['Geplant – separat','planned']].forEach(([label,key])=>{part.append(element('h4',label));const rows=(data.medications?.[key]||[]).map(item=>[item.date,item.name,item.dose||'Dosis nicht dokumentiert']);rows.length?part.append(table(['Datum','Medikament','Dosis'],rows)):empty(part);});} + if(data.selected_sections.includes('medications')){const part=section('Medikamentenverlauf');[['Tatsächlich verabreicht','administered'],['Ausgelassen','missed'],['Korrigiert','corrected'],['Geplant – separat','planned'],['Status unbekannt – nicht interpretiert','unknown']].forEach(([label,key])=>{part.append(element('h4',label));const rows=(data.medications?.[key]||[]).map(item=>{const marker=item.superseded_by_correction?'Durch spätere Korrektur ersetzt · ':(item.is_correction&&item.is_latest_effective?'Wirksame Korrektur · ':'');const detail=item.status==='corrected'?`Zielstatus: ${item.effective_status||'unbekannt'} · ${medicationDose(item)}`:medicationDose(item);return[item.date,item.name,`${marker}${detail}`];});rows.length?part.append(table(['Datum','Medikament','Dokumentierte Angabe'],rows)):empty(part);});} if(data.selected_sections.includes('supplements')){const part=section('Supplemente');[['Tatsächlich eingenommen','administered'],['Ausgelassen','missed'],['Korrigiert','corrected'],['Geplant – separat','planned']].forEach(([label,key])=>{part.append(element('h4',label));const rows=(data.supplements?.[key]||[]).map(item=>[item.date,item.product,`${item.amount} ${item.unit} ${item.nutrient}`,item.composition_source,item.assignment_reliability]);rows.length?part.append(table(['Datum','Präparat','Dokumentierte Menge','Zusammensetzungsquelle','Zuordnung'],rows)):empty(part);});part.append(element('p','Dokumentierte Einnahme; keine Dosierungsempfehlung oder Aussage über einen Bedarf.','doctor-summary-disclaimer'));} if(data.selected_sections.includes('labs')){const part=section('Relevante Laborverläufe');const rows=(data.labs||[]).map(item=>[item.date,item.parameter,`${text(item.display_value ?? `${item.operator||''}${item.value}`)} ${text(item.unit)}`]);rows.length?part.append(table(['Datum','Parameter','Wert'],rows)):empty(part);} if(data.selected_sections.includes('observations')){const part=section('Schlaf, Kreislauf und Aktivität');(data.observations||[]).forEach(metric=>{part.append(element('h4',metric.label||'Körperwert'));const rows=(metric.points||[]).filter(point=>point.value!==null&&point.value!==undefined).map(point=>[point.date,`${point.value} ${metric.unit||''}`.trim()]);rows.length?part.append(table(['Datum','Dokumentierter Wert'],rows)):empty(part);});if(!(data.observations||[]).length)empty(part);} diff --git a/scripts/health/dashboard_v5/capture_contract.py b/scripts/health/dashboard_v5/capture_contract.py index adc61f5..9d1c0ed 100644 --- a/scripts/health/dashboard_v5/capture_contract.py +++ b/scripts/health/dashboard_v5/capture_contract.py @@ -8,6 +8,8 @@ from datetime import datetime from typing import Any from zoneinfo import ZoneInfo +from dashboard_v5.medication_contract import ACTION_CONTRACT_VERSION, validate_action_data + CONTRACT_VERSION = 1 TIMEZONE = ZoneInfo("Europe/Zurich") CAPTURE_TYPES = frozenset( @@ -227,6 +229,8 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "ongoing": data["ongoing"], } + elif capture_type == "medication" and data.get("contract") == ACTION_CONTRACT_VERSION: + normalized = validate_action_data(data) elif capture_type in {"medication", "supplement"}: expected = common | { "status", @@ -375,8 +379,9 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "description": _text(data["description"], 160), } - normalized["title"] = _text(data["title"], 100, required=capture_type == "event") - normalized["note"] = _text(data["note"], 500) + if not (capture_type == "medication" and normalized.get("contract") == ACTION_CONTRACT_VERSION): + normalized["title"] = _text(data["title"], 100, required=capture_type == "event") + normalized["note"] = _text(data["note"], 500) if capture_type == "event" and normalized.get("event_kind") in {"sauna", "training"}: if ended is None or occurred is None: raise ValueError("duration requires end") diff --git a/scripts/health/health_dashboard_server.py b/scripts/health/health_dashboard_server.py index 949948e..58e878b 100644 --- a/scripts/health/health_dashboard_server.py +++ b/scripts/health/health_dashboard_server.py @@ -33,6 +33,11 @@ from dashboard_v5.document_originals import configured_original_roots, probe_ori from dashboard_v5.supplement_contract import validate_supplement_payload from dashboard_v5.observation_contract import validate_action as validate_observation_action 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, + resolve_action_preview, +) from dashboard_v5.capture_media import MAX_ORIGINAL_BYTES as MAX_CAPTURE_MEDIA_BYTES, quarantine_bytes from dashboard_v5.media_validation import media_runtime_self_test from dashboard_v5.document_review import discard_quarantine, quarantine_upload, validate_metadata @@ -150,6 +155,7 @@ CAPTURE_UPLOAD_ROUTE = "/api/v1/capture/upload" DOCUMENT_UPLOAD_ROUTE = "/api/v1/documents/upload" CAPTURE_ROUTE = "/health-actions/capture" +MEDICATION_PREVIEW_ROUTE = "/health-actions/medication-preview" ACTION_INBOX = Path( os.environ.get( @@ -1272,6 +1278,7 @@ class Handler(BaseHTTPRequestHandler): OBSERVATION_ROUTE, DOCUMENT_REVIEW_ROUTE, CAPTURE_ROUTE, + MEDICATION_PREVIEW_ROUTE, }: self.send_error(404) return @@ -1311,7 +1318,7 @@ class Handler(BaseHTTPRequestHandler): token = (form.get("csrf_token") or [""])[0] cookie = SimpleCookie(self.headers.get("Cookie", "")) cookie_token = cookie.get( - "health_capture_csrf" if action_path == CAPTURE_ROUTE else "health_csrf" + "health_capture_csrf" if action_path in {CAPTURE_ROUTE, MEDICATION_PREVIEW_ROUTE} else "health_csrf" ) if ( not token @@ -1320,10 +1327,11 @@ class Handler(BaseHTTPRequestHandler): ): self.send_error(403) return - if not consume_csrf_token(token): + if action_path != MEDICATION_PREVIEW_ROUTE and not consume_csrf_token(token): self.send_error(403) return mapping_queue_key_value = "" + medication_preview_revision = "" return_document = "" payload: dict[str, object] = {} @@ -1335,6 +1343,33 @@ class Handler(BaseHTTPRequestHandler): payload, return_to = validate_mapping_submission(form) mapping_queue_key_value = str(payload["queue_key"]) write_action_payload(payload) + elif action_path == MEDICATION_PREVIEW_ROUTE: + if ( + set(form) != {"csrf_token", "payload", "return_to"} + or any(len(values) != 1 for values in form.values()) + or form["return_to"][0] != "v5" + or API_DB is None + ): + raise ValueError("invalid medication preview submission") + payload = validate_capture_payload(json.loads(form["payload"][0])) + preview_data = payload.get("data") + if ( + payload["capture_type"] != "medication" + or not isinstance(preview_data, dict) + or preview_data.get("contract") != MEDICATION_ACTION_CONTRACT + ): + raise ValueError("invalid medication preview contract") + preview_connection = connect_read_only(API_DB) + try: + assert_medication_schema(preview_connection) + 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" elif action_path == CAPTURE_ROUTE: if ( set(form) != {"csrf_token", "payload", "return_to"} @@ -1369,6 +1404,13 @@ class Handler(BaseHTTPRequestHandler): except OSError: self.send_error(500) return + if action_path == MEDICATION_PREVIEW_ROUTE: + self._send_api_json( + 200, + {"status": "preview", "preview_revision": medication_preview_revision}, + True, + ) + return if action_path in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE} and "application/json" in self.headers.get( "Accept", "" ): __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__