appointments.go
⎇
Raw
1package web
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "html/template"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "rsc.io/qr"
14
15 "forge.gpl-nas.de/physiobase/internal/ical"
16 "forge.gpl-nas.de/physiobase/internal/store"
17)
18
19var weekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
20
21func dayLabel(t time.Time) string {
22 return weekdays[t.Weekday()] + ", " + t.Format("02.01.2006")
23}
24
25func startOfDay(t time.Time) time.Time {
26 y, m, d := t.Date()
27 return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
28}
29
30// parseAppt liest die gemeinsamen Terminfelder aus dem Formular.
31func parseAppt(r *http.Request) (store.Appointment, error) {
32 start, err := time.ParseInLocation("2006-01-02 15:04", formText(r, "date")+" "+formText(r, "time"), time.Local)
33 if err != nil {
34 return store.Appointment{}, errors.New("Datum oder Uhrzeit fehlt bzw. ist ungültig") //nolint:staticcheck // ST1005: Meldung wird direkt angezeigt
35 }
36 dur, err := strconv.Atoi(formText(r, "duration"))
37 if err != nil || dur < 5 || dur > 480 {
38 dur = 30
39 }
40 a := store.Appointment{
41 Start: start, Duration: dur, Title: formText(r, "title"),
42 Location: formText(r, "location"), Note: formText(r, "note"),
43 }
44 if a.Title == "" {
45 a.Title = "Physiotherapie"
46 }
47 a.TherapistID, _ = formID(r, "therapist")
48 return a, nil
49}
50
51// ---------- Praxisübersicht ----------
52
53type apptDay struct {
54 Label string
55 Today bool
56 Appts []store.Appointment
57}
58
59func (s *Server) listAppointments(w http.ResponseWriter, r *http.Request) {
60 ctx := r.Context()
61 from := startOfDay(time.Now())
62 if v := r.URL.Query().Get("von"); v != "" {
63 if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
64 from = t
65 }
66 }
67 days := 14
68 therapist, _ := strconv.ParseInt(r.URL.Query().Get("behandler"), 10, 64)
69 as, err := s.st.AppointmentsBetween(ctx, from, from.AddDate(0, 0, days), therapist)
70 if err != nil {
71 s.fail(w, r, err)
72 return
73 }
74 var grouped []apptDay
75 for _, a := range as {
76 label := dayLabel(a.Start)
77 if len(grouped) == 0 || grouped[len(grouped)-1].Label != label {
78 grouped = append(grouped, apptDay{Label: label, Today: startOfDay(a.Start).Equal(startOfDay(time.Now()))})
79 }
80 grouped[len(grouped)-1].Appts = append(grouped[len(grouped)-1].Appts, a)
81 }
82 therapists, err := s.st.Therapists(ctx, false)
83 if err != nil {
84 s.fail(w, r, err)
85 return
86 }
87 patients, err := s.st.Patients(ctx, "", false)
88 if err != nil {
89 s.fail(w, r, err)
90 return
91 }
92 s.render(w, r, "appointments", http.StatusOK, map[string]any{
93 "Days": grouped, "From": from.Format("2006-01-02"), "To": from.AddDate(0, 0, days-1),
94 "Prev": from.AddDate(0, 0, -days).Format("2006-01-02"), "Next": from.AddDate(0, 0, days).Format("2006-01-02"),
95 "Therapists": therapists, "Therapist": therapist, "Patients": patients, "Count": len(as),
96 "Today": time.Now().Format("2006-01-02"), "Address": s.cfg.Address, "Error": r.URL.Query().Get("fehler"),
97 })
98}
99
100// createAppointment legt einen Termin oder eine Serie an (z. B. 6× wöchentlich für eine Verordnung).
101func (s *Server) createAppointment(w http.ResponseWriter, r *http.Request) {
102 if !limitForm(w, r) {
103 return
104 }
105 ctx := r.Context()
106 pid, ok := formID(r, "patient")
107 back := func(msg string) {
108 if r.FormValue("back") == "patient" && ok {
109 redirect(w, r, "/patient/%d?fehler=%s#termine", pid, urlPathEscape(msg))
110 return
111 }
112 redirect(w, r, "/termine?fehler=%s", urlPathEscape(msg))
113 }
114 if !ok {
115 back("Bitte einen Patienten auswählen.")
116 return
117 }
118 if _, err := s.st.Patient(ctx, pid); err != nil {
119 s.fail(w, r, err)
120 return
121 }
122 a, err := parseAppt(r)
123 if err != nil {
124 back(err.Error())
125 return
126 }
127 a.PatientID = pid
128 count, _ := strconv.Atoi(r.FormValue("repeat"))
129 count = max(1, min(count, 40))
130 every, _ := strconv.Atoi(r.FormValue("every"))
131 every = max(1, min(every, 60))
132 for i := range count {
133 x := a
134 x.Start = a.Start.AddDate(0, 0, i*every) // AddDate hält die Uhrzeit auch über die Zeitumstellung
135 if _, err := s.st.CreateAppointment(ctx, x); err != nil {
136 s.fail(w, r, err)
137 return
138 }
139 }
140 if r.FormValue("back") == "patient" {
141 redirect(w, r, "/patient/%d#termine", pid)
142 return
143 }
144 redirect(w, r, "/termine?von=%s", startOfDay(a.Start).Format("2006-01-02"))
145}
146
147func (s *Server) showAppointment(w http.ResponseWriter, r *http.Request) {
148 id, ok := pathID(r, "id")
149 if !ok {
150 http.NotFound(w, r)
151 return
152 }
153 a, err := s.st.Appointment(r.Context(), id)
154 if err != nil {
155 s.fail(w, r, err)
156 return
157 }
158 therapists, err := s.st.Therapists(r.Context(), false)
159 if err != nil {
160 s.fail(w, r, err)
161 return
162 }
163 s.render(w, r, "appointment", http.StatusOK, map[string]any{"A": a, "Therapists": therapists, "Error": r.URL.Query().Get("fehler")})
164}
165
166func (s *Server) saveAppointment(w http.ResponseWriter, r *http.Request) {
167 if !limitForm(w, r) {
168 return
169 }
170 id, ok := pathID(r, "id")
171 if !ok {
172 http.NotFound(w, r)
173 return
174 }
175 old, err := s.st.Appointment(r.Context(), id)
176 if err != nil {
177 s.fail(w, r, err)
178 return
179 }
180 a, err := parseAppt(r)
181 if err != nil {
182 redirect(w, r, "/termin/%d?fehler=%s", id, urlPathEscape(err.Error()))
183 return
184 }
185 a.ID, a.PatientID, a.Cancelled = id, old.PatientID, r.FormValue("cancelled") == "1"
186 if err := s.st.UpdateAppointment(r.Context(), a); err != nil {
187 s.fail(w, r, err)
188 return
189 }
190 redirect(w, r, "/patient/%d#termine", a.PatientID)
191}
192
193// toggleCancel sagt einen Termin ab bzw. wieder zu; im Kalender erscheint er als abgesagt.
194func (s *Server) toggleCancel(w http.ResponseWriter, r *http.Request) {
195 id, ok := pathID(r, "id")
196 if !ok {
197 http.NotFound(w, r)
198 return
199 }
200 a, err := s.st.Appointment(r.Context(), id)
201 if err != nil {
202 s.fail(w, r, err)
203 return
204 }
205 a.Cancelled = !a.Cancelled
206 if err := s.st.UpdateAppointment(r.Context(), a); err != nil {
207 s.fail(w, r, err)
208 return
209 }
210 if to := r.FormValue("back"); to == "termine" {
211 redirect(w, r, "/termine?von=%s", startOfDay(a.Start).Format("2006-01-02"))
212 return
213 }
214 redirect(w, r, "/patient/%d#termine", a.PatientID)
215}
216
217func (s *Server) deleteAppointment(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.DeleteAppointment(r.Context(), id)
224 if err != nil {
225 s.fail(w, r, err)
226 return
227 }
228 redirect(w, r, "/patient/%d#termine", pid)
229}
230
231// ---------- Behandler ----------
232
233func (s *Server) createTherapist(w http.ResponseWriter, r *http.Request) {
234 if !limitForm(w, r) {
235 return
236 }
237 if name := formText(r, "name"); name != "" {
238 if err := s.st.CreateTherapist(r.Context(), name); err != nil {
239 s.fail(w, r, err)
240 return
241 }
242 }
243 redirect(w, r, "/termine#behandler")
244}
245
246func (s *Server) updateTherapist(w http.ResponseWriter, r *http.Request) {
247 if !limitForm(w, r) {
248 return
249 }
250 id, ok := pathID(r, "id")
251 name := formText(r, "name")
252 if !ok || name == "" {
253 http.Error(w, "Name fehlt", http.StatusBadRequest)
254 return
255 }
256 if err := s.st.UpdateTherapist(r.Context(), store.Therapist{ID: id, Name: name, Active: r.FormValue("active") == "1"}); err != nil {
257 s.fail(w, r, err)
258 return
259 }
260 redirect(w, r, "/termine#behandler")
261}
262
263// ---------- Kalender-Abo ----------
264
265// calURL: Abo-Adresse. Ohne PHYSIOBASE_PUBLIC_URL nur im lokalen Netz erreichbar.
266func (s *Server) calURL(r *http.Request, token string) (string, bool) {
267 base, public := s.cfg.PublicURL, true
268 if base == "" {
269 scheme := "http"
270 if r.TLS != nil {
271 scheme = "https"
272 }
273 base, public = scheme+"://"+r.Host, false
274 }
275 return base + "/kalender/" + token + ".ics", public
276}
277
278func webcal(u string) string {
279 if i := strings.Index(u, "://"); i >= 0 {
280 return "webcal" + u[i:]
281 }
282 return u
283}
284
285// calendarFeed ist öffentlich (Kalender-Apps können sich nicht anmelden) und nur über den
286// geheimen Token erreichbar. Er enthält bewusst keine Diagnosen und keinen Patientennamen.
287func (s *Server) calendarFeed(w http.ResponseWriter, r *http.Request) {
288 ctx := r.Context()
289 token := strings.TrimSuffix(r.PathValue("file"), ".ics")
290 pid, err := s.st.PatientByCalToken(ctx, token)
291 if err != nil {
292 if errors.Is(err, store.ErrNotFound) {
293 time.Sleep(500 * time.Millisecond) // Raten unattraktiv machen
294 http.NotFound(w, r)
295 return
296 }
297 s.fail(w, r, err)
298 return
299 }
300 as, err := s.st.PatientAppointments(ctx, pid, time.Now().AddDate(0, 0, -90))
301 if err != nil {
302 s.fail(w, r, err)
303 return
304 }
305 host := "physiobase"
306 if i := strings.LastIndex(s.cfg.SMTP.From, "@"); i >= 0 {
307 host = s.cfg.SMTP.From[i+1:]
308 }
309 cal := ical.Calendar{Name: s.cfg.PracticeName + " – Termine", ProdID: "-//PhysioBase//Termine//DE"}
310 for _, a := range as {
311 var desc []string
312 if a.TherapistName != "" {
313 desc = append(desc, "Behandlung bei: "+a.TherapistName)
314 }
315 if a.Note != "" {
316 desc = append(desc, "Hinweis: "+a.Note)
317 }
318 if a.Cancelled {
319 desc = append(desc, "Dieser Termin wurde abgesagt.")
320 }
321 summary := a.Title + " – " + s.cfg.PracticeName
322 if a.Cancelled {
323 summary = "Abgesagt: " + summary
324 }
325 loc := a.Location
326 if loc == "" {
327 loc = s.cfg.Address
328 }
329 cal.Events = append(cal.Events, ical.Event{
330 UID: a.UID + "@" + host, Start: a.Start, End: a.End(), Stamp: a.Updated,
331 Summary: summary, Description: strings.Join(desc, "\n"), Location: loc,
332 Sequence: a.Sequence, Cancelled: a.Cancelled,
333 })
334 }
335 w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
336 w.Header().Set("Content-Disposition", `inline; filename="termine.ics"`)
337 w.Header().Set("Cache-Control", "private, max-age=900")
338 w.Header().Set("X-Robots-Tag", "noindex")
339 _, _ = w.Write(cal.Bytes())
340}
341
342// qrSVG zeichnet einen QR-Code als SVG (scharf in jeder Druckgröße).
343func qrSVG(text string) ([]byte, error) {
344 code, err := qr.Encode(text, qr.M)
345 if err != nil {
346 return nil, err
347 }
348 const quiet = 4
349 n := code.Size + 2*quiet
350 var b bytes.Buffer
351 fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
352 fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
353 for y := range code.Size {
354 for x := range code.Size {
355 if code.Black(x, y) {
356 fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
357 }
358 }
359 }
360 b.WriteString(`"/></svg>`)
361 return b.Bytes(), nil
362}
363
364func (s *Server) calendarQR(w http.ResponseWriter, r *http.Request) {
365 pid, ok := pathID(r, "id")
366 if !ok {
367 http.NotFound(w, r)
368 return
369 }
370 tok, err := s.st.CalToken(r.Context(), pid)
371 if err != nil {
372 s.fail(w, r, err)
373 return
374 }
375 u, _ := s.calURL(r, tok)
376 svg, err := qrSVG(webcal(u))
377 if err != nil {
378 s.fail(w, r, err)
379 return
380 }
381 w.Header().Set("Content-Type", "image/svg+xml")
382 w.Header().Set("Cache-Control", "private, no-store")
383 _, _ = w.Write(svg) //nolint:gosec // G705: SVG wird hier aus dem QR-Raster erzeugt, kein Benutzertext
384}
385
386func (s *Server) rotateCalendar(w http.ResponseWriter, r *http.Request) {
387 pid, ok := pathID(r, "id")
388 if !ok {
389 http.NotFound(w, r)
390 return
391 }
392 if _, err := s.st.RotateCalToken(r.Context(), pid); err != nil {
393 s.fail(w, r, err)
394 return
395 }
396 redirect(w, r, "/patient/%d#termine", pid)
397}
398
399func (s *Server) printAppointments(w http.ResponseWriter, r *http.Request) {
400 ctx := r.Context()
401 pid, ok := pathID(r, "id")
402 if !ok {
403 http.NotFound(w, r)
404 return
405 }
406 p, err := s.st.Patient(ctx, pid)
407 if err != nil {
408 s.fail(w, r, err)
409 return
410 }
411 as, err := s.st.PatientAppointments(ctx, pid, startOfDay(time.Now()))
412 if err != nil {
413 s.fail(w, r, err)
414 return
415 }
416 tok, err := s.st.CalToken(ctx, pid)
417 if err != nil {
418 s.fail(w, r, err)
419 return
420 }
421 u, public := s.calURL(r, tok)
422 s.render(w, r, "appointments_print", http.StatusOK, map[string]any{
423 "P": p, "Appts": as, "CalURL": u, "Public": public, "Address": s.cfg.Address, "Today": time.Now(),
424 })
425}
426
427// patientAppointmentData für die Patientenseite.
428func (s *Server) patientAppointmentData(r *http.Request, pid int64) (map[string]any, error) {
429 ctx := r.Context()
430 all, err := s.st.PatientAppointments(ctx, pid, time.Time{})
431 if err != nil {
432 return nil, err
433 }
434 now := time.Now()
435 var upcoming, past []store.Appointment
436 for _, a := range all {
437 if a.End().After(now) {
438 upcoming = append(upcoming, a)
439 } else {
440 past = append(past, a)
441 }
442 }
443 for i, j := 0, len(past)-1; i < j; i, j = i+1, j-1 { // vergangene: neueste zuerst
444 past[i], past[j] = past[j], past[i]
445 }
446 therapists, err := s.st.Therapists(ctx, true)
447 if err != nil {
448 return nil, err
449 }
450 tok, err := s.st.CalToken(ctx, pid)
451 if err != nil {
452 return nil, err
453 }
454 u, public := s.calURL(r, tok)
455 return map[string]any{
456 "Upcoming": upcoming, "Past": past, "Therapists": therapists,
457 // webcal:// kennt html/template nicht als sicheres Schema; die Adresse bauen wir selbst.
458 "CalURL": u, "Webcal": template.URL(webcal(u)), "CalPublic": public, "Address": s.cfg.Address, //nolint:gosec // G203
459 }, nil
460}
461