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