diff --git a/scripts/health/assets/health-assets/dashboard-v5-record.js b/scripts/health/assets/health-assets/dashboard-v5-record.js index 9482935..f76812f 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,89 @@ 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;form.addEventListener('input',()=>{if(frozen){frozen=null;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 body=new URLSearchParams({csrf_token:shell.dataset.recordCsrf||'',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; + } 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:shell.dataset.recordCsrf||'',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 +874,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/assets/health-assets/dashboard-v5.css b/scripts/health/assets/health-assets/dashboard-v5.css index d1ba339..8d47b92 100644 --- a/scripts/health/assets/health-assets/dashboard-v5.css +++ b/scripts/health/assets/health-assets/dashboard-v5.css @@ -1430,3 +1430,23 @@ dialog input, dialog select, dialog textarea { width: 100%; min-height: 44px; fo @media (prefers-reduced-motion:reduce) { .comparison-workspace * { scroll-behavior:auto!important; transition:none!important; } } + +.medication-current,.medication-history-list { display:grid; gap:10px; } +.medication-prescription-row,.medication-history-row { min-width:0; padding:12px; border:1px solid var(--v5-border); border-radius:var(--v5-radius); background:var(--v5-surface-tint); } +.medication-prescription-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px 12px; } +.medication-prescription-row p,.medication-prescription-row button { grid-column:1/-1; } +.medication-history-row > div,.medication-history-row dd { overflow-wrap:anywhere; } +.medication-history-row dl { display:grid; grid-template-columns:minmax(9rem,.55fr) minmax(0,1.45fr); gap:5px 12px; } +.medication-history-row dt { color:var(--v5-muted); font-weight:700; } +.medication-history-row dd { margin:0; } +.medication-action-dialog { width:min(680px,calc(100vw - 24px)); max-height:92dvh; overflow:auto; padding:clamp(14px,3vw,24px); border:1px solid var(--v5-border); border-radius:var(--v5-radius-lg); color:var(--v5-ink); background:var(--v5-surface); } +.medication-action-dialog::backdrop { background:rgba(15,32,43,.54); } +.medication-action-dialog form,.medication-extra-fields { display:grid; gap:10px; } +.medication-action-preview { padding:12px; border:1px solid var(--v5-accent); border-radius:var(--v5-radius); background:var(--v5-surface-tint); } +@media (max-width:430px) { + .medication-prescription-row { grid-template-columns:minmax(0,1fr); } + .medication-history-row dl { grid-template-columns:minmax(0,1fr); gap:2px; } + .medication-history-row dd { margin-bottom:7px; } + .medication-action-dialog { width:calc(100vw - 12px); max-width:none; margin:3dvh 6px; } + .medication-action-dialog button,.medication-action-dialog input,.medication-action-dialog select,.medication-action-dialog textarea { min-height:44px; } +} 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/dashboard_v5/read_api.py b/scripts/health/dashboard_v5/read_api.py index 94af38b..62ef74c 100644 --- a/scripts/health/dashboard_v5/read_api.py +++ b/scripts/health/dashboard_v5/read_api.py @@ -79,6 +79,17 @@ from dashboard_v5.supplement_read import supplement_nutrient_totals, supplements from dashboard_v5.source_status import build_source_status from dashboard_v5.observation_contract import PUBLIC_CONTRACT_VERSION, STATUS as OBSERVATION_STATUS, templates as observation_templates from dashboard_v5.observation_engine import evaluate_observation, evaluate_plan, list_observations, observation_detail +from dashboard_v5.medication_schema import assert_schema as assert_medication_schema +from dashboard_v5.medication_contract import ( + PUBLIC_CONTRACT_VERSION as MEDICATION_HISTORY_CONTRACT, + event_ref as medication_event_ref, + list_events as medication_event_rows, + list_prescriptions as medication_prescription_rows, + medication_ref as medication_prescription_ref, + public_action_context_token as medication_public_context_token, + public_identity_key as medication_public_identity_key, + plan_is_consumed as medication_plan_is_consumed, +) TZ_NAME = "Europe/Zurich" LOCAL_TZ = ZoneInfo(TZ_NAME) @@ -100,7 +111,7 @@ MAX_QUERY_BYTES = 512 QUERY_TIMEOUT_SECONDS = 1.0 ALLOWED_RESOLUTIONS = frozenset({"day", "week"}) ALLOWED_EVENT_TYPES = frozenset( - {"medication_administered", "supplement", "symptom_day", "health_event", "health_period", "nutrition_day", "laboratory", "document", "appointment"} + {"medication_administered", "medication", "supplement", "symptom_day", "health_event", "health_period", "nutrition_day", "laboratory", "document", "appointment"} ) NUTRIENT_ALLOWLIST = { key: (contract.label, contract.unit) @@ -1005,6 +1016,28 @@ def _events(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, result.append( {"date": day, "type": "medication_administered", "label": label} ) + if "medication" in selected and table_exists(connection, "medication_administrations"): + rows = list(connection.execute( + """SELECT datum,medication_name,event_type FROM medication_administrations + WHERE datum>=? AND datum<=? ORDER BY datum,id LIMIT ?""", + (start.isoformat(), end.isoformat(), MAX_EVENT_SOURCE_ROWS + 1), + )) + if len(rows) > MAX_EVENT_SOURCE_ROWS: + raise APIError(422, "source_row_limit_exceeded") + for row in rows: + raw = str(row["event_type"] or "").strip().casefold() + if raw in ADMINISTERED: + continue + category = ( + "planned" if raw in {"planned", "scheduled", "geplant"} + else "missed" if raw in {"missed", "verpasst", "ausgelassen"} + else "corrected" if raw in {"corrected", "korrigiert", "correction"} + else "unknown" + ) + label = safe_metadata_text(row["medication_name"], 120) + day = parse_day(row["datum"]) + if day and label: + result.append({"date": day, "type": "medication", "category": category, "label": label}) if "supplement" in selected: try: supplement_events = supplements(connection, start, end) @@ -3479,71 +3512,193 @@ def _record_medications( connection: sqlite3.Connection, params: dict[str, str] ) -> dict[str, Any]: start, end = parse_range(params) - result: dict[str, Any] = { + medication_filter = params.get("medication", "") + if medication_filter and not re.fullmatch(r"medrx_[a-f0-9]{24}", medication_filter): + raise APIError(400, "medication_filter_not_allowed") + status_filter = params.get("status", "") + raw_source_filter = params.get("source", "") + source_filter = safe_metadata_text(raw_source_filter, 80, allow_empty=True) + if raw_source_filter and source_filter is None: + raise APIError(400, "source_filter_not_allowed") + if status_filter and status_filter not in {"planned", "administered", "missed", "corrected", "unknown"}: + raise APIError(400, "medication_status_not_allowed") + empty: dict[str, Any] = { + "contract": MEDICATION_HISTORY_CONTRACT, + "prescriptions": [], + "current_prescriptions": [], + "other_prescriptions": [], "planned": [], "administered": [], "missed": [], "corrected": [], + "unknown": [], + "sources": [], + "truncated": False, + "truncated_sections": [], + "next_cursor": None, } - if not table_exists(connection, "medication_administrations"): - return result - groups = { - "planned": ("planned", "scheduled", "geplant"), - "administered": tuple(sorted(ADMINISTERED)), - "missed": ("missed", "verpasst", "ausgelassen"), - "corrected": ("corrected", "korrigiert", "correction"), + if not table_exists(connection, "medication_administrations") or not table_exists(connection, "medikamente"): + return empty + try: + assert_medication_schema(connection) + except RuntimeError as error: + raise APIError(503, "medication_schema_unavailable") from error + identity_key = medication_public_identity_key(connection) + prescriptions = medication_prescription_rows(connection) + prescription_by_id = {int(row["id"]): row for row in prescriptions} + prescription_by_name: dict[str, list[sqlite3.Row]] = defaultdict(list) + for row in prescriptions: + prescription_by_name[str(row["medikament_name"])].append(row) + public_prescriptions = [] + for row in prescriptions: + name = safe_metadata_text(row["medikament_name"], 120) + dose = safe_metadata_text(row["dosierung"], 80, allow_empty=True) + route = safe_metadata_text(row["anwendungsform"], 80, allow_empty=True) + source = safe_metadata_text(row["prescription_status_source"], 80, allow_empty=True) + provenance = safe_metadata_text(row["prescription_status_provenance"], 160, allow_empty=True) + status = str(row["prescription_status"] or "unknown") + trusted_status = bool( + status in {"active", "ended", "paused"} + and source + and provenance + and row["business_revision"] + ) + if not trusted_status or None in (name, dose, route): + status = "unknown" + item = { + "id": medication_prescription_ref(row, identity_key), + "name": name, + "documented_dose": dose, + "documented_route": route, + "status": status, + "status_source": source, + "status_provenance": provenance, + "preview_revision": medication_public_context_token(identity_key, row), + } + public_prescriptions.append(item) + empty["prescriptions"] = public_prescriptions + empty["current_prescriptions"] = [item for item in public_prescriptions if item["status"] == "active"] + empty["other_prescriptions"] = [item for item in public_prescriptions if item["status"] != "active"] + + rows = medication_event_rows(connection) + corrected_origins = { + int(row["corrects_event_id"]) + for row in rows + if str(row["event_type"] or "").strip().casefold() == "corrected" and row["corrects_event_id"] is not None + } + aliases = { + "oral": "oral", "subkutan": "subcutaneous", "subcutaneous": "subcutaneous", + "intravenös": "intravenous", "intravenous": "intravenous", + "intramuskulär": "intramuscular", "intramuscular": "intramuscular", + "äußerlich": "topical", "topical": "topical", "inhalativ": "inhaled", "inhaled": "inhaled", } - truncated_sections: list[str] = [] - for bucket, events in groups.items(): - effective = ( - "COALESCE(NULLIF(scheduled_next_date,''),datum)" + sources: set[str] = set() + truncated_sections: set[str] = set() + for row in rows: + raw_status = str(row["event_type"] or "").strip().casefold() + if raw_status in {"planned", "scheduled", "geplant"}: + bucket = "planned" + elif raw_status in ADMINISTERED: + bucket = "administered" + elif raw_status in {"missed", "verpasst", "ausgelassen"}: + bucket = "missed" + elif raw_status in {"corrected", "korrigiert", "correction"}: + bucket = "corrected" + else: + bucket = "unknown" + recorded_day = parse_day(row["datum"]) + scheduled = parse_day(row["scheduled_next_date"]) + effective_day = scheduled or recorded_day if bucket == "planned" else recorded_day + if not recorded_day or not effective_day: + continue + if start and effective_day < start.isoformat(): + continue + if end and effective_day > end.isoformat(): + continue + name = safe_metadata_text(row["medication_name"], 120) + source = safe_metadata_text(row["source"], 80, allow_empty=True) + if not name or source is None: + continue + if status_filter and bucket != status_filter: + continue + if source_filter and source != source_filter: + continue + candidates: list[sqlite3.Row] = [] + if row["medication_id"] is not None and int(row["medication_id"]) in prescription_by_id: + candidates = [prescription_by_id[int(row["medication_id"])]] + elif len(prescription_by_name.get(name, [])) == 1: + candidates = prescription_by_name[name] + prescription = candidates[0] if len(candidates) == 1 else None + if medication_filter and ( + prescription is None + or medication_prescription_ref(prescription, identity_key) != medication_filter + ): + continue + route_original = safe_metadata_text(row["route_original"] or row["route"], 60, allow_empty=True) + route_normalized = safe_metadata_text(row["route_normalized"], 24, allow_empty=True) + if not route_normalized and route_original: + route_normalized = aliases.get(route_original.casefold(), "unknown") + planned_context = None + if row["planned_event_id"] is not None: + planned_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["planned_event_id"])), None) + correction_context = None + if row["corrects_event_id"] is not None: + correction_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["corrects_event_id"])), None) + plan_consumed = ( + medication_plan_is_consumed(connection, int(row["id"])) if bucket == "planned" - else "datum" + else False ) - placeholders = ",".join("?" for _ in events) - filters = [f"lower(trim(COALESCE(event_type,''))) IN ({placeholders})"] - values: list[Any] = list(events) - if start: - filters.append(f"{effective}>=?") - values.append(start.isoformat()) - if end: - filters.append(f"{effective}<=?") - values.append(end.isoformat()) - direction = "ASC" if bucket == "planned" else "DESC" - rows = connection.execute( - "SELECT datum,medication_name,dose,route,event_type," - "scheduled_next_date,notes,source FROM medication_administrations WHERE " - + " AND ".join(filters) - + f" ORDER BY {effective} {direction},id {direction} LIMIT ?", - (*values, RECORD_MAX_ROWS + 1), - ).fetchall() - if len(rows) > RECORD_MAX_ROWS: - truncated_sections.append(bucket) - for row in rows[:RECORD_MAX_ROWS]: - recorded_day = parse_day(row["datum"]) - scheduled = parse_day(row["scheduled_next_date"]) - effective_day = ( - scheduled or recorded_day if bucket == "planned" else recorded_day - ) - name = safe_metadata_text(row["medication_name"], 120) - if not recorded_day or not effective_day or not name: - continue - result[bucket].append( - { - "date": effective_day, - "recorded_date": recorded_day, - "scheduled_next_date": scheduled if bucket == "planned" else None, - "name": name, - "dose": safe_metadata_text(row["dose"], 40, allow_empty=True), - "route": safe_metadata_text(row["route"], 40, allow_empty=True), - "note": safe_metadata_text(row["notes"], 300, allow_empty=True), - "source": safe_metadata_text(row["source"], 80, allow_empty=True), - } - ) - result["truncated"] = bool(truncated_sections) - result["truncated_sections"] = truncated_sections - result["next_cursor"] = None - return result + item = { + "id": medication_event_ref(row, identity_key), + "date": effective_day, + "recorded_date": recorded_day, + "occurred_at": safe_metadata_text(row["occurred_at"], 40, allow_empty=True), + "scheduled_next_date": scheduled, + "name": name, + "status": bucket, + "effective_status": safe_metadata_text(row["corrected_target_status"], 20, allow_empty=True) if bucket == "corrected" else bucket, + "legacy_dose": safe_metadata_text(row["dose"], 80, allow_empty=True), + "planned_dose_value": safe_metadata_text(row["planned_dose_value"], 40, allow_empty=True), + "planned_dose_unit": safe_metadata_text(row["planned_dose_unit"], 30, allow_empty=True), + "actual_dose_value": safe_metadata_text(row["actual_dose_value"], 40, allow_empty=True), + "actual_dose_unit": safe_metadata_text(row["actual_dose_unit"], 30, allow_empty=True), + "route_original": route_original, + "route_normalized": route_normalized or "unknown", + "injection_region": safe_metadata_text(row["injection_region"], 80, allow_empty=True), + "injection_side": safe_metadata_text(row["injection_side"], 20, allow_empty=True), + "injection_detail": safe_metadata_text(row["injection_detail"], 120, allow_empty=True), + "lot_number": safe_metadata_text(row["lot_number"], 80, allow_empty=True), + "note": safe_metadata_text(row["notes"], 300, allow_empty=True), + "source": source, + "prescription_id": medication_prescription_ref(prescription, identity_key) if prescription is not None else None, + "planned_event_id": medication_event_ref(planned_context, identity_key) if planned_context is not None else None, + "corrects_event_id": medication_event_ref(correction_context, identity_key) if correction_context is not None else None, + "correction_reason": safe_metadata_text(row["correction_reason"], 300, allow_empty=True), + "superseded_by_correction": int(row["id"]) in corrected_origins, + "plan_consumed": plan_consumed, + "administration_preview_revision": medication_public_context_token( + identity_key, + prescription, + row, + None, + planned_consumed=plan_consumed, + ) if prescription is not None and bucket == "planned" and not plan_consumed else None, + "correction_preview_revision": medication_public_context_token(identity_key, prescription, None, row) if prescription is not None and int(row["id"]) not in corrected_origins else None, + } + if len(empty[bucket]) >= RECORD_MAX_ROWS: + truncated_sections.add(bucket) + continue + empty[bucket].append(item) + if source: + sources.add(source) + for bucket in ("administered", "missed", "corrected", "unknown"): + empty[bucket].sort(key=lambda item: (item["date"], item["id"]), reverse=True) + empty["planned"].sort(key=lambda item: (item["date"], item["id"])) + empty["sources"] = sorted(sources) + empty["truncated"] = bool(truncated_sections) + empty["truncated_sections"] = sorted(truncated_sections) + return empty def _record_appointments( @@ -3724,24 +3879,28 @@ def _next_planned_medications( if not table_exists(connection, "medication_administrations"): return [] rows = connection.execute( - """SELECT datum,medication_name,dose,route,scheduled_next_date,notes,source - FROM medication_administrations - WHERE lower(trim(COALESCE(event_type,''))) IN ('planned','scheduled','geplant') - AND scheduled_next_date>? - ORDER BY scheduled_next_date ASC,id ASC LIMIT ?""", - (today.isoformat(), limit), + """SELECT p.id,p.datum,p.medication_name,p.dose,p.route,p.scheduled_next_date,p.notes,p.source, + COALESCE(NULLIF(p.scheduled_next_date,''),NULLIF(substr(p.occurred_at,1,10),''),p.datum) AS effective_plan_date + FROM medication_administrations p + WHERE lower(trim(COALESCE(p.event_type,''))) IN ('planned','scheduled','geplant') + AND COALESCE(NULLIF(p.scheduled_next_date,''),NULLIF(substr(p.occurred_at,1,10),''),p.datum)>? + ORDER BY effective_plan_date ASC,p.id ASC""", + (today.isoformat(),), ) result = [] for row in rows: + if medication_plan_is_consumed(connection, int(row["id"])): + continue recorded = parse_day(row["datum"]) - scheduled = parse_day(row["scheduled_next_date"]) + effective = parse_day(row["effective_plan_date"]) + explicit_schedule = parse_day(row["scheduled_next_date"]) name = safe_metadata_text(row["medication_name"], 120) - if recorded and scheduled and name: + if recorded and effective and name: result.append( { - "date": scheduled, + "date": effective, "recorded_date": recorded, - "scheduled_next_date": scheduled, + "scheduled_next_date": explicit_schedule, "name": name, "dose": safe_metadata_text(row["dose"], 40, allow_empty=True), "route": safe_metadata_text(row["route"], 40, allow_empty=True), @@ -3749,6 +3908,8 @@ def _next_planned_medications( "source": safe_metadata_text(row["source"], 80, allow_empty=True), } ) + if len(result) >= limit: + break return result @@ -4048,13 +4209,30 @@ def _doctor_report( else {"observations": [], "truncated": False} ) labs = lab_page["observations"] - medications = ( + medication_record = ( _record_medications( connection, {"from": start.isoformat(), "to": end.isoformat()} ) if "medications" in selected - else {"planned": [], "administered": [], "missed": [], "corrected": []} + else {"planned": [], "administered": [], "missed": [], "corrected": [], "unknown": [], "truncated": False} + ) + medication_report_fields = ( + "date", "name", "status", "effective_status", "scheduled_next_date", "planned_dose_value", + "planned_dose_unit", "actual_dose_value", "actual_dose_unit", "legacy_dose", + "superseded_by_correction", ) + medications: dict[str, Any] = { + kind: [ + { + **{field: item.get(field) for field in medication_report_fields}, + "is_correction": kind == "corrected", + "is_latest_effective": not bool(item.get("superseded_by_correction")), + } + for item in medication_record.get(kind, []) + ] + for kind in ("planned", "administered", "missed", "corrected", "unknown") + } + medications["truncated"] = bool(medication_record.get("truncated")) supplement_report = ( supplements(connection, start, end) if "supplements" in selected @@ -4235,7 +4413,7 @@ def _doctor_report( "labs": labs, "medications": sum( len(medications.get(kind, [])) - for kind in ("planned", "administered", "missed", "corrected") + for kind in ("planned", "administered", "missed", "corrected", "unknown") ), "supplements": sum( len(supplement_report.get(kind, [])) @@ -4436,7 +4614,7 @@ def dispatch_api( except ValueError as error: raise APIError(422, str(error)) from error if path == "/api/v1/medications": - return _record_medications(connection, parse_query(query, {"from", "to"})) + return _record_medications(connection, parse_query(query, {"from", "to", "medication", "status", "source"})) if path == "/api/v1/appointments": return _record_appointments( connection, parse_query(query, {"from", "to", "order", "institution"}) diff --git a/scripts/health/health_dashboard_action_worker.py b/scripts/health/health_dashboard_action_worker.py index 7a2da28..5efcc3a 100644 --- a/scripts/health/health_dashboard_action_worker.py +++ b/scripts/health/health_dashboard_action_worker.py @@ -33,6 +33,11 @@ try: assert_schema as assert_observation_schema, ) 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 cleanup_expired, promote_attachment from dashboard_v5.media_validation import MediaInfo, create_safe_derivatives, media_runtime_self_test from dashboard_v5.sprint6i_b_schema import ( @@ -85,6 +90,11 @@ except ModuleNotFoundError: # direct importlib fixture execution assert_schema as assert_observation_schema, ) 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 cleanup_expired, promote_attachment from dashboard_v5.media_validation import MediaInfo, create_safe_derivatives, media_runtime_self_test from dashboard_v5.sprint6i_b_schema import ( @@ -1028,11 +1038,44 @@ def apply_capture_action(payload: dict[str, Any]) -> str: if not root_id: root_id = entry_id data = payload["data"] + medication_context: dict[str, Any] | None = None known_tables = { str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") } - if payload["capture_type"] == "medication" and "medication_administrations" in known_tables: + if payload["capture_type"] == "medication" and data.get("contract") == MEDICATION_ACTION_CONTRACT: + assert_medication_schema(connection) + expected_revision, prescription, planned, correction_target = resolve_action_preview( + connection, payload + ) + 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 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, + "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: known_medications = { str(row[0]) for row in connection.execute( @@ -1179,22 +1222,62 @@ def apply_capture_action(payload: dict[str, Any]) -> str: (day, label, severity, context, data["note"] or None), ) elif payload["capture_type"] == "medication" and "medication_administrations" in available_tables: - dose = " ".join( - str(part) - for part in (data["amount"], data["unit"]) - if part not in {None, ""} - ) - medication_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(medication_administrations)")} - if "occurred_at" in medication_columns: + 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,notes,source,occurred_at) VALUES(?,?,?,?,?,?,?,?)", - (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture", payload["occurred_at"]), + """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: - connection.execute( - "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source) VALUES(?,?,?,?,?,?,?)", - (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture"), + dose = " ".join( + str(part) + for part in (data["amount"], data["unit"]) + if part not in {None, ""} ) + medication_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(medication_administrations)")} + if "occurred_at" in medication_columns: + connection.execute( + "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source,occurred_at) VALUES(?,?,?,?,?,?,?,?)", + (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture", payload["occurred_at"]), + ) + else: + connection.execute( + "INSERT INTO medication_administrations(datum,medication_name,dose,route,event_type,notes,source) VALUES(?,?,?,?,?,?,?)", + (day, data["name"], dose or None, data["route"] or None, data["status"], data["note"] or None, "dashboard_v5_mobile_capture"), + ) elif payload["capture_type"] == "supplement" and "supplement_intakes" in available_tables: connection.execute( "INSERT INTO supplement_intakes(plan_id,product,brand_variant,nutrient_key,amount,unit,status,occurred_at,composition_source,assignment_reliability,notes) VALUES(NULL,?,NULL,'other',?,?,?,?, 'user_documented','documented',?)", 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", "" ): diff --git a/tests/fixtures/dashboard_v5_fixture.py b/tests/fixtures/dashboard_v5_fixture.py index ec678a4..cca6ba5 100644 --- a/tests/fixtures/dashboard_v5_fixture.py +++ b/tests/fixtures/dashboard_v5_fixture.py @@ -227,6 +227,15 @@ def build_dashboard_v5_fixture( ) ], ) + connection.executemany( + "INSERT INTO medikamente(medikament_name,dosierung,anwendungsform,prescription_status,prescription_status_source,prescription_status_provenance,ermittlung_datum) VALUES(?,?,?,?,?,?,?)", + [ + ("SYNTHETIC_EVENT_ON_MEASUREMENT_GAP", "10 mg", "synthetic", "unknown", "fixture", "synthetic fixture", fixture_timestamp), + ("SYNTHETIC_ADMINISTERED_MEDICATION", "10 mg", "synthetic", "active", "fixture", "synthetic fixture", fixture_timestamp), + ("SYNTHETIC_PLANNED_MEDICATION_WITH_A_VERY_LONG_LABEL_FOR_RESPONSIVE_LAYOUT", "10 mg", "synthetic", "active", "fixture", "synthetic fixture", fixture_timestamp), + ("SYNTHETIC_CANCELLED_MEDICATION", "10 mg", "synthetic", "ended", "fixture", "synthetic fixture", fixture_timestamp), + ], + ) connection.executemany( """INSERT INTO medication_administrations (datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,created_at) @@ -345,6 +354,17 @@ def build_dashboard_v5_fixture( worker_module.apply_document_schema(connection) worker_module.apply_media_schema(connection) importlib.import_module("dashboard_v5.sprint6i_c_schema").apply_schema(connection) + importlib.import_module("dashboard_v5.medication_schema").apply_schema(connection) + connection.execute( + "UPDATE medikamente SET business_revision=printf('%064x', id) WHERE prescription_status IN ('active','paused','ended')" + ) + connection.execute( + "UPDATE medication_public_identity_key SET key=? WHERE singleton=1", + (bytes.fromhex("7cf0" * 16),), + ) + connection.execute( + "UPDATE medication_schema_meta SET installed_at='2026-06-15 12:00:00'" + ) recomputed_days = [*exposure_days, current] for nutrition_day in recomputed_days: worker_module.recompute_nutrition_day(connection, _iso_day(nutrition_day)) __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__