patients.go
⎇
Raw
1package web
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "regexp"
9 "strings"
10 "time"
11
12 "forge.gpl-nas.de/physiobase/internal/befund"
13 "forge.gpl-nas.de/physiobase/internal/store"
14)
15
16var isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
17
18func validDate(s string) string {
19 if isoDate.MatchString(s) {
20 return s
21 }
22 return time.Now().Format("2006-01-02")
23}
24
25// ---------- Patientenliste ----------
26
27func (s *Server) listPatients(w http.ResponseWriter, r *http.Request) {
28 q := strings.TrimSpace(r.URL.Query().Get("q"))
29 archived := r.URL.Query().Get("archiv") == "1"
30 ps, err := s.st.Patients(r.Context(), q, archived)
31 if err != nil {
32 s.fail(w, r, err)
33 return
34 }
35 s.render(w, r, "patients", http.StatusOK, map[string]any{
36 "Patients": ps, "Q": q, "Archived": archived, "Imported": r.URL.Query().Get("importiert"),
37 })
38}
39
40func (s *Server) createPatient(w http.ResponseWriter, r *http.Request) {
41 if !limitForm(w, r) {
42 return
43 }
44 p := store.Patient{Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone")}
45 if p.Name == "" {
46 http.Error(w, "Name fehlt", http.StatusBadRequest)
47 return
48 }
49 id, err := s.st.CreatePatient(r.Context(), p)
50 if err != nil {
51 s.fail(w, r, err)
52 return
53 }
54 redirect(w, r, "/patient/%d", id)
55}
56
57// ---------- Patient ----------
58
59func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
60 ctx := r.Context()
61 id, ok := pathID(r, "id")
62 if !ok {
63 http.NotFound(w, r)
64 return
65 }
66 p, err := s.st.Patient(ctx, id)
67 if err != nil {
68 s.fail(w, r, err)
69 return
70 }
71 bs, err := s.st.Befunde(ctx, id)
72 if err != nil {
73 s.fail(w, r, err)
74 return
75 }
76 sessions, err := s.st.Sessions(ctx, id)
77 if err != nil {
78 s.fail(w, r, err)
79 return
80 }
81 linked, err := s.st.PatientEntries(ctx, id)
82 if err != nil {
83 s.fail(w, r, err)
84 return
85 }
86 linkable, err := s.st.EntriesOfKind(ctx, "krankheitsbild", "protokoll", "notiz")
87 if err != nil {
88 s.fail(w, r, err)
89 return
90 }
91 handouts, err := s.st.HandoutsForPatient(ctx, id)
92 if err != nil {
93 s.fail(w, r, err)
94 return
95 }
96 // Verlauf der Kernwerte über alle Befunde (älteste zuerst)
97 type trendRow struct {
98 Date, Title string
99 Now, Worst, PSFS, Tests string
100 ID int64
101 }
102 var trend []trendRow
103 for i := len(bs) - 1; i >= 0; i-- {
104 d, _ := befund.Parse(bs[i].Data)
105 trend = append(trend, trendRow{
106 ID: bs[i].ID, Date: bs[i].Date, Title: bs[i].Title,
107 Now: d.Get("pain_now"), Worst: d.Get("pain_worst"), PSFS: befund.PSFSMean(d),
108 Tests: fmt.Sprint(len(d.Tables["measures"])),
109 })
110 }
111 s.render(w, r, "patient", http.StatusOK, map[string]any{
112 "P": p, "Befunde": bs, "Sessions": sessions, "Linked": linked, "Linkable": linkable,
113 "Handouts": handouts, "Trend": trend, "Today": time.Now().Format("2006-01-02"),
114 })
115}
116
117func (s *Server) updatePatient(w http.ResponseWriter, r *http.Request) {
118 if !limitForm(w, r) {
119 return
120 }
121 id, ok := pathID(r, "id")
122 if !ok {
123 http.NotFound(w, r)
124 return
125 }
126 p := store.Patient{ID: id, Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone"), Notes: formText(r, "notes")}
127 if p.Name == "" {
128 http.Error(w, "Name fehlt", http.StatusBadRequest)
129 return
130 }
131 if err := s.st.UpdatePatient(r.Context(), p); err != nil {
132 s.fail(w, r, err)
133 return
134 }
135 redirect(w, r, "/patient/%d", id)
136}
137
138func (s *Server) archivePatient(w http.ResponseWriter, r *http.Request) {
139 id, ok := pathID(r, "id")
140 if !ok {
141 http.NotFound(w, r)
142 return
143 }
144 if err := s.st.SetArchived(r.Context(), id, r.FormValue("archiv") == "1"); err != nil {
145 s.fail(w, r, err)
146 return
147 }
148 redirect(w, r, "/patient/%d", id)
149}
150
151func (s *Server) deletePatient(w http.ResponseWriter, r *http.Request) {
152 id, ok := pathID(r, "id")
153 if !ok {
154 http.NotFound(w, r)
155 return
156 }
157 if err := s.st.DeletePatient(r.Context(), id); err != nil {
158 s.fail(w, r, err)
159 return
160 }
161 redirect(w, r, "/patienten")
162}
163
164func (s *Server) addSession(w http.ResponseWriter, r *http.Request) {
165 if !limitForm(w, r) {
166 return
167 }
168 id, ok := pathID(r, "id")
169 if !ok {
170 http.NotFound(w, r)
171 return
172 }
173 x := store.Session{PatientID: id, Date: validDate(formText(r, "date")), Goal: formText(r, "goal"), Note: formText(r, "note")}
174 if x.Goal != "" || x.Note != "" {
175 if err := s.st.AddSession(r.Context(), x); err != nil {
176 s.fail(w, r, err)
177 return
178 }
179 }
180 redirect(w, r, "/patient/%d#verlauf", id)
181}
182
183func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
184 id, ok := pathID(r, "id")
185 if !ok {
186 http.NotFound(w, r)
187 return
188 }
189 pid, err := s.st.DeleteSession(r.Context(), id)
190 if err != nil {
191 s.fail(w, r, err)
192 return
193 }
194 redirect(w, r, "/patient/%d#verlauf", pid)
195}
196
197func (s *Server) linkPatientEntry(w http.ResponseWriter, r *http.Request) {
198 if !limitForm(w, r) {
199 return
200 }
201 id, ok1 := pathID(r, "id")
202 eid, ok2 := formID(r, "entry")
203 if !ok1 || !ok2 {
204 http.Error(w, "Bitte einen Eintrag auswählen", http.StatusBadRequest)
205 return
206 }
207 if err := s.st.LinkPatientEntry(r.Context(), id, eid); err != nil {
208 s.fail(w, r, err)
209 return
210 }
211 redirect(w, r, "/patient/%d#wissen", id)
212}
213
214func (s *Server) unlinkPatientEntry(w http.ResponseWriter, r *http.Request) {
215 id, ok1 := pathID(r, "id")
216 eid, ok2 := pathID(r, "eid")
217 if !ok1 || !ok2 {
218 http.NotFound(w, r)
219 return
220 }
221 if err := s.st.UnlinkPatientEntry(r.Context(), id, eid); err != nil {
222 s.fail(w, r, err)
223 return
224 }
225 redirect(w, r, "/patient/%d#wissen", id)
226}
227
228func (s *Server) createPatientHandout(w http.ResponseWriter, r *http.Request) {
229 if !limitForm(w, r) {
230 return
231 }
232 id, ok := pathID(r, "id")
233 if !ok {
234 http.NotFound(w, r)
235 return
236 }
237 p, err := s.st.Patient(r.Context(), id)
238 if err != nil {
239 s.fail(w, r, err)
240 return
241 }
242 title := formText(r, "title")
243 if title == "" {
244 title = "Heimübungsprogramm"
245 }
246 hid, err := s.st.CreateHandoutFor(r.Context(), title, p)
247 if err != nil {
248 s.fail(w, r, err)
249 return
250 }
251 redirect(w, r, "/handout/%d", hid)
252}
253
254// ---------- Befund ----------
255
256// newBefund: leer oder als Kopie des letzten Befunds (Wiederbefund – nur Werte neu messen).
257func (s *Server) newBefund(w http.ResponseWriter, r *http.Request) {
258 if !limitForm(w, r) {
259 return
260 }
261 ctx := r.Context()
262 pid, ok := pathID(r, "id")
263 if !ok {
264 http.NotFound(w, r)
265 return
266 }
267 if _, err := s.st.Patient(ctx, pid); err != nil {
268 s.fail(w, r, err)
269 return
270 }
271 b := store.Befund{PatientID: pid, Date: time.Now().Format("2006-01-02"), Title: "Erstbefund"}
272 if src, ok := formID(r, "kopie"); ok {
273 old, err := s.st.Befund(ctx, src)
274 if err != nil || old.PatientID != pid {
275 http.Error(w, "Vorlage nicht gefunden", http.StatusBadRequest)
276 return
277 }
278 b.Data, b.Title = old.Data, "Wiederbefund"
279 }
280 if t := formText(r, "title"); t != "" {
281 b.Title = t
282 }
283 id, err := s.st.CreateBefund(ctx, b)
284 if err != nil {
285 s.fail(w, r, err)
286 return
287 }
288 redirect(w, r, "/befund/%d", id)
289}
290
291func (s *Server) loadBefund(w http.ResponseWriter, r *http.Request) (store.Befund, store.Patient, befund.Data, bool) {
292 ctx := r.Context()
293 id, ok := pathID(r, "id")
294 if !ok {
295 http.NotFound(w, r)
296 return store.Befund{}, store.Patient{}, befund.Data{}, false
297 }
298 b, err := s.st.Befund(ctx, id)
299 if err != nil {
300 s.fail(w, r, err)
301 return b, store.Patient{}, befund.Data{}, false
302 }
303 p, err := s.st.Patient(ctx, b.PatientID)
304 if err != nil {
305 s.fail(w, r, err)
306 return b, p, befund.Data{}, false
307 }
308 d, err := befund.Parse(b.Data)
309 if err != nil {
310 s.fail(w, r, err)
311 return b, p, d, false
312 }
313 return b, p, d, true
314}
315
316func (s *Server) showBefund(w http.ResponseWriter, r *http.Request) {
317 b, p, d, ok := s.loadBefund(w, r)
318 if !ok {
319 return
320 }
321 tests, err := s.st.EntriesOfKind(r.Context(), "test")
322 if err != nil {
323 s.fail(w, r, err)
324 return
325 }
326 s.render(w, r, "befund", http.StatusOK, map[string]any{
327 "B": b, "P": p, "D": d, "Sections": befund.Sections, "Symbols": befund.Symbols, "Views": befund.Views,
328 "Tests": tests, "Measures": befund.Measures, "Saved": r.URL.Query().Get("gespeichert") == "1",
329 })
330}
331
332func (s *Server) saveBefund(w http.ResponseWriter, r *http.Request) {
333 r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
334 if err := r.ParseForm(); err != nil {
335 http.Error(w, "Formular ungültig oder zu groß", http.StatusBadRequest)
336 return
337 }
338 b, _, _, ok := s.loadBefund(w, r)
339 if !ok {
340 return
341 }
342 b.Date = validDate(formText(r, "date"))
343 b.Title = formText(r, "title")
344 if b.Title == "" {
345 b.Title = "Befund"
346 }
347 b.Data = befund.FromForm(r.PostForm).JSON()
348 if err := s.st.UpdateBefund(r.Context(), b); err != nil {
349 s.fail(w, r, err)
350 return
351 }
352 if r.FormValue("next") == "druck" {
353 redirect(w, r, "/befund/%d/druck", b.ID)
354 return
355 }
356 redirect(w, r, "/befund/%d?gespeichert=1", b.ID)
357}
358
359func (s *Server) deleteBefund(w http.ResponseWriter, r *http.Request) {
360 id, ok := pathID(r, "id")
361 if !ok {
362 http.NotFound(w, r)
363 return
364 }
365 pid, err := s.st.DeleteBefund(r.Context(), id)
366 if err != nil {
367 s.fail(w, r, err)
368 return
369 }
370 redirect(w, r, "/patient/%d", pid)
371}
372
373func (s *Server) printBefund(w http.ResponseWriter, r *http.Request) {
374 b, p, d, ok := s.loadBefund(w, r)
375 if !ok {
376 return
377 }
378 var sessions []store.Session
379 if r.URL.Query().Get("verlauf") == "1" {
380 var err error
381 if sessions, err = s.st.Sessions(r.Context(), p.ID); err != nil {
382 s.fail(w, r, err)
383 return
384 }
385 }
386 s.render(w, r, "befund_print", http.StatusOK, map[string]any{
387 "B": b, "P": p, "D": d, "Sections": befund.Sections, "Views": befund.Views, "Sessions": sessions, "Today": time.Now(),
388 })
389}
390
391// Export im eigenen Format (lässt sich wieder importieren).
392type exportFile struct {
393 Format string `json:"format"`
394 Patient exportPatient `json:"patient"`
395 Date string `json:"date"`
396 Title string `json:"title"`
397 Data json.RawMessage `json:"data"`
398}
399type exportPatient struct {
400 Name string `json:"name"`
401 Dob string `json:"dob"`
402}
403
404const exportFormat = "physiobase-befund-1"
405
406func (s *Server) exportBefund(w http.ResponseWriter, r *http.Request) {
407 b, p, _, ok := s.loadBefund(w, r)
408 if !ok {
409 return
410 }
411 name := strings.Map(func(c rune) rune {
412 if c == ' ' || c == '/' || c == '\\' || c == '"' {
413 return '_'
414 }
415 return c
416 }, p.Name)
417 w.Header().Set("Content-Type", "application/json")
418 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="Befund_%s_%s.json"`, name, b.Date))
419 enc := json.NewEncoder(w)
420 enc.SetIndent("", " ")
421 if err := enc.Encode(exportFile{Format: exportFormat, Patient: exportPatient{p.Name, p.Dob}, Date: b.Date, Title: b.Title, Data: json.RawMessage(b.Data)}); err != nil {
422 s.log.Error("export", "befund", b.ID, "err", err)
423 }
424}
425
426// ---------- Vergleich ----------
427
428func (s *Server) compareBefunde(w http.ResponseWriter, r *http.Request) {
429 ctx := r.Context()
430 pid, ok := pathID(r, "id")
431 if !ok {
432 http.NotFound(w, r)
433 return
434 }
435 p, err := s.st.Patient(ctx, pid)
436 if err != nil {
437 s.fail(w, r, err)
438 return
439 }
440 bs, err := s.st.Befunde(ctx, pid)
441 if err != nil {
442 s.fail(w, r, err)
443 return
444 }
445 data := map[string]any{"P": p, "Befunde": bs}
446 if len(bs) >= 2 {
447 // Standard: ältester (Baseline) gegen neuesten
448 aID, bID := bs[len(bs)-1].ID, bs[0].ID
449 if v, ok := formID(r, "a"); ok {
450 aID = v
451 }
452 if v, ok := formID(r, "b"); ok {
453 bID = v
454 }
455 var A, B *store.Befund
456 for i := range bs {
457 if bs[i].ID == aID {
458 A = &bs[i]
459 }
460 if bs[i].ID == bID {
461 B = &bs[i]
462 }
463 }
464 if A != nil && B != nil {
465 da, _ := befund.Parse(A.Data)
466 db, _ := befund.Parse(B.Data)
467 fields, tables := befund.Compare(da, db)
468 data["A"], data["B"], data["Fields"], data["Tables"] = A, B, fields, tables
469 }
470 }
471 s.render(w, r, "compare", http.StatusOK, data)
472}
473
474// ---------- Import alter Befunde ----------
475
476func (s *Server) importBefunde(w http.ResponseWriter, r *http.Request) {
477 ctx := r.Context()
478 r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
479 if err := r.ParseMultipartForm(8 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt
480 http.Error(w, "Upload fehlgeschlagen", http.StatusBadRequest)
481 return
482 }
483 defer func() { _ = r.MultipartForm.RemoveAll() }()
484 var count int
485 var errs []string
486 for _, fh := range r.MultipartForm.File["files"] {
487 f, err := fh.Open()
488 if err != nil {
489 errs = append(errs, fh.Filename+": "+err.Error())
490 continue
491 }
492 raw, err := io.ReadAll(io.LimitReader(f, 10<<20))
493 _ = f.Close()
494 if err != nil {
495 errs = append(errs, fh.Filename+": "+err.Error())
496 continue
497 }
498 if err := s.importOne(r, raw); err != nil {
499 errs = append(errs, fh.Filename+": "+err.Error())
500 continue
501 }
502 count++
503 }
504 if len(errs) > 0 {
505 s.log.Warn("import teilweise fehlgeschlagen", "fehler", errs)
506 ps, _ := s.st.Patients(ctx, "", false)
507 s.render(w, r, "patients", http.StatusBadRequest, map[string]any{
508 "Patients": ps, "Imported": fmt.Sprint(count), "ImportErrors": errs,
509 })
510 return
511 }
512 redirect(w, r, "/patienten?importiert=%d", count)
513}
514
515func (s *Server) importOne(r *http.Request, raw []byte) error {
516 ctx := r.Context()
517 var probe struct {
518 Format string `json:"format"`
519 }
520 _ = json.Unmarshal(raw, &probe) // kein eigenes Format → alte Befundseite
521
522 var name, dob, date, title string
523 var data befund.Data
524 var sessions []befund.Row
525 if probe.Format == exportFormat {
526 var ef exportFile
527 if err := json.Unmarshal(raw, &ef); err != nil {
528 return err
529 }
530 d, err := befund.Parse(string(ef.Data))
531 if err != nil {
532 return err
533 }
534 d.Stamps = befund.CleanStamps(d.Stamps)
535 name, dob, date, title, data = ef.Patient.Name, ef.Patient.Dob, ef.Date, ef.Title, d
536 } else {
537 l, err := befund.ParseLegacy(raw)
538 if err != nil {
539 return err
540 }
541 name, dob, date, title, data, sessions = l.Name, l.Dob, l.Date, "Befund (Import)", l.Data, l.Sessions
542 }
543 if strings.TrimSpace(name) == "" {
544 name = "Unbekannt (Import)"
545 }
546 pid, found, err := s.st.FindPatient(ctx, name, dob)
547 if err != nil {
548 return err
549 }
550 if !found {
551 if pid, err = s.st.CreatePatient(ctx, store.Patient{Name: name, Dob: dob}); err != nil {
552 return err
553 }
554 }
555 if _, err := s.st.CreateBefund(ctx, store.Befund{PatientID: pid, Date: validDate(date), Title: title, Data: data.JSON()}); err != nil {
556 return err
557 }
558 for _, x := range sessions {
559 if x["goal"] == "" && x["note"] == "" {
560 continue
561 }
562 if err := s.st.AddSession(ctx, store.Session{PatientID: pid, Date: validDate(x["date"]), Goal: x["goal"], Note: x["note"]}); err != nil {
563 return err
564 }
565 }
566 return nil
567}
568