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