Add patient documents, patient summaries and email

- Patient documents: upload scanned PDFs and photos per patient with
  title, category and date; served only when logged in, not cached,
  removed together with the patient.
- Summaries for patients: overall summary built from the latest
  assessment, home programme and progress; one per treatment session.
  Lines starting with ✎ are therapist notes and never printed or sent.
- Email: send summaries over SMTP (implicit TLS or STARTTLS) as text and
  HTML. Requires a stored address and recorded consent; every attempt
  is logged.
- README: document the new features and configuration, and disclose
  that the project, including its clinical content, was written with an
  LLM coding assistant.

Assisted-by: Claude (Anthropic) via the pi coding agent
AuthorAdmin <admin@gpl-nas.de>
Date
Commit7dbe7c76812b5dff94180a9d36c3e6def6bf6096
Parent1e158a4
22 files changed, 1517 insertions(+), 24 deletions(-)
▾MREADME.md
@@ -20,8 +20,13 @@ conveniences; everything else works without it.
folding: `knie` finds *Kniegelenk*, `ubung` finds *Übung*.
- **Exercise handouts**: pick exercises, set dosage and a personal note,
reorder, copy as a template, print or save as PDF (A4 print view).
- **Patients**: assessments, treatment log, linked knowledge entries and home
programmes on one page.
- **Patients**: assessments, treatment log, documents, linked knowledge
entries, home programmes and summaries on one page.
- **Patient documents**: scanned PDFs and photos (doctor's letters,
prescriptions, imaging, progress photos) with title, category and date.
Several files per upload; phones and tablets can take the photo directly.
Files are only served to a logged-in user, never cached, and deleted
together with the patient.
- **Assessment form (Befund)**: ICF-oriented and evidence-based, including
red/yellow flag screening, NRS and pain mechanism, a body chart with symbol
stamps, ROM/strength/girth tables, special tests, PROMs and performance tests,
@@ -34,6 +39,14 @@ conveniences; everything else works without it.
Neutral-Null notation (`0-5-100` → `0-0-125` gives `-5 / +25`).
- **Print view**: only filled fields, body chart with legend, optional
treatment log, signature lines.
- **Summaries for patients**: an overall summary (complaint, findings, goals,
plan, home exercises, progress, when to get in touch) and one per treatment
session. Both start as a draft built from the recorded data and are edited
in plain language before they go out. Lines starting with `✎` are notes for
the therapist and never appear in print or email.
- **Email**: send a summary to the patient over SMTP (text and HTML).
Requires an address and recorded consent in the patient record; every
attempt is logged with time, recipient and result.
- **Import**: JSON exports of the previous standalone assessment page
(`Befund_*.json`) and of PhysioBase itself.
- **Backups**: consistent SQLite snapshots (`VACUUM INTO`) at start and every
@@ -98,11 +111,18 @@ are embedded in the binary.
| `PHYSIOBASE_PORT` | `8080` | Listen port |
| `PHYSIOBASE_DATA_DIR` | `./data` | Root for everything below |
| `PHYSIOBASE_DB_PATH` | `<data>/physiobase.db` | SQLite database |
| `PHYSIOBASE_MEDIA_DIR` | `<data>/medien` | Uploaded images, videos, PDFs |
| `PHYSIOBASE_MEDIA_DIR` | `<data>/medien` | Uploads of the knowledge base |
| `PHYSIOBASE_DOCS_DIR` | `<data>/patienten` | Patient documents (scans, photos) |
| `PHYSIOBASE_BACKUP_DIR` | `<data>/backup` | Daily database backups |
| `PHYSIOBASE_BACKUP_KEEP` | `14` | Backups to keep; `0` turns them off |
| `PHYSIOBASE_PRACTICE_NAME` | `PhysioBase` | Shown in the header and on printouts |
| `PHYSIOBASE_BASE_URL` | — | External URL; an `https://` value makes cookies `Secure` and enables HSTS |
| `PHYSIOBASE_SMTP_HOST` | — | SMTP server; empty turns email off |
| `PHYSIOBASE_SMTP_PORT` | `587` | SMTP port |
| `PHYSIOBASE_SMTP_SECURITY` | `starttls` | `ssl` (implicit TLS, usually 465), `starttls` (587) or `none` (tests only) |
| `PHYSIOBASE_SMTP_USER` | — | SMTP login |
| `PHYSIOBASE_SMTP_PASSWORD` | — | SMTP password |
| `PHYSIOBASE_SMTP_FROM` | `$PHYSIOBASE_SMTP_USER` | Sender address; the practice name is the display name |
| `PHYSIOBASE_SEED` | `1` | Insert sample entries into an empty database |
| `PHYSIOBASE_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
| `PHYSIOBASE_LOG_FORMAT` | `text` | `text` or `json`; logs go to stderr |
@@ -121,6 +141,10 @@ mode without login.
`script-src 'self'`.
- Cross-site POSTs are rejected through Go's `http.CrossOriginProtection`.
- Uploaded files are only served if they belong to an entry in the database.
- Email goes only to the single address stored for the patient, only with
recorded consent, and addresses are validated against header injection.
Sending health information by email needs the patient's explicit consent
(Art. 9 GDPR); keep the signed consent, for example as a patient document.
**Health data:** once real patient data is stored, run it behind HTTPS with
access limited to people who need it. Encryption at rest is not built in
@@ -131,10 +155,15 @@ yet; put the data directory on an encrypted volume.
```
data/
├── physiobase.db SQLite (WAL mode)
├── medien/ uploads
├── medien/ uploads of the knowledge base
├── patienten/ patient documents
└── backup/ physiobase-YYYY-MM-DD_HHMMSS.db
```
The backups cover the database only. Uploads and patient documents are plain
files that never change after upload; back up `medien/` and `patienten/`
with your usual file backup or snapshots.
Do not copy `physiobase.db` while the server runs. In WAL mode, recent writes can
still be in `physiobase.db-wal`. Use the files in `backup/`; each one is a
complete, consistent database.
@@ -153,6 +182,26 @@ gofumpt -l -extra .
The same checks, plus `go mod tidy` and `govulncheck`, run in CI on every push
(see [`.hearthforge-ci.toml`](.hearthforge-ci.toml)).
## Use of AI
This project was written with an AI coding assistant: Claude by Anthropic, a
large language model, working through the pi coding agent.
The maintainer chose the features, gave direction and decided what went in;
the model wrote most of the code, the tests, this README, and the clinical
content.
That clinical content includes the structure and wording of the assessment
form, the red/yellow flag lists, the suggested assessment instruments, the
sample knowledge entries and the patient summary templates. It was drafted
by the model from general physiotherapy knowledge, **not** from a reviewed
clinical source, and has not been checked by an independent clinician.
Check it against current guidelines before you rely on it. The software
supports documentation; it does not make clinical decisions.
The code is checked by automated tests, `go vet`, golangci-lint (including
gosec), gofumpt and govulncheck on every push. That catches many mistakes,
but not all. Review it before you trust it with patient data.
## License
[GNU Affero General Public License v3.0](LICENSE.md). If you run a modified
▾Mcmd/physiobase/main.go
@@ -56,8 +56,10 @@ func run() error {
log := slog.New(h)
slog.SetDefault(log)
if err := os.MkdirAll(cfg.MediaDir, 0o750); err != nil {
return fmt.Errorf("medienordner: %w", err)
for _, dir := range []string{cfg.MediaDir, cfg.DocsDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("ordner %s: %w", dir, err)
}
}
db, err := database.Open(cfg.DBPath)
if err != nil {
▾Minternal/config/config.go
@@ -16,7 +16,9 @@ type Config struct {
DataDir string
DBPath string
MediaDir string
DocsDir string
BackupDir string
SMTP SMTP
BackupKeep int
Username string
PasswordHash string
@@ -27,6 +29,15 @@ type Config struct {
LogJSON bool
}
// SMTP für den Versand von Zusammenfassungen. Leerer Host = Versand aus.
type SMTP struct {
Host, User, Password, From string
Port int
Security string // "ssl" (implizites TLS, 465), "starttls" (587) oder "none" (nur Tests/LAN)
}
func (s SMTP) Enabled() bool { return s.Host != "" && s.From != "" }
func (c *Config) IsHTTPS() bool { return strings.HasPrefix(c.BaseURL, "https://") }
func get(k, def string) string {
@@ -48,6 +59,7 @@ func Load() (*Config, error) {
}
c.DBPath = get("DB_PATH", filepath.Join(c.DataDir, "physiobase.db"))
c.MediaDir = get("MEDIA_DIR", filepath.Join(c.DataDir, "medien"))
c.DocsDir = get("DOCS_DIR", filepath.Join(c.DataDir, "patienten"))
c.BackupDir = get("BACKUP_DIR", filepath.Join(c.DataDir, "backup"))
var err error
@@ -57,6 +69,18 @@ func Load() (*Config, error) {
if c.BackupKeep, err = strconv.Atoi(get("BACKUP_KEEP", "14")); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_BACKUP_KEEP ungültig: %w", err)
}
c.SMTP = SMTP{
Host: get("SMTP_HOST", ""), User: get("SMTP_USER", ""), Password: get("SMTP_PASSWORD", ""),
From: get("SMTP_FROM", get("SMTP_USER", "")), Security: get("SMTP_SECURITY", "starttls"),
}
if c.SMTP.Port, err = strconv.Atoi(get("SMTP_PORT", "587")); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_SMTP_PORT ungültig: %w", err)
}
switch c.SMTP.Security {
case "ssl", "starttls", "none":
default:
return nil, fmt.Errorf("PHYSIOBASE_SMTP_SECURITY muss ssl, starttls oder none sein, nicht %q", c.SMTP.Security)
}
if c.Seed, err = strconv.ParseBool(get("SEED", "1")); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_SEED ungültig: %w", err)
}
▾Minternal/database/database.go
@@ -186,6 +186,52 @@ CREATE TABLE patient_entries (
ALTER TABLE handouts ADD COLUMN patient_id INTEGER REFERENCES patients(id) ON DELETE SET NULL;
CREATE INDEX idx_handouts_patient ON handouts(patient_id);
`},
{3, `
-- Eingescannte/fotografierte Unterlagen je Patient (Arztbrief, Verordnung, Bildgebung …)
CREATE TABLE patient_docs (
id INTEGER PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
filename TEXT NOT NULL UNIQUE,
original TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_patient_docs_patient ON patient_docs(patient_id, date);
`},
{4, `
-- Zusammenfassungen für Patienten: gesamt oder zu einer Behandlung
CREATE TABLE summaries (
id INTEGER PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
kind TEXT NOT NULL CHECK (kind IN ('gesamt', 'sitzung')),
date TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_summaries_patient ON summaries(patient_id, date);
`},
{5, `
ALTER TABLE patients ADD COLUMN email TEXT NOT NULL DEFAULT '';
ALTER TABLE patients ADD COLUMN email_consent INTEGER NOT NULL DEFAULT 0;
-- Versandprotokoll (auch Fehlversuche)
CREATE TABLE mail_log (
id INTEGER PRIMARY KEY,
summary_id INTEGER REFERENCES summaries(id) ON DELETE SET NULL,
patient_id INTEGER REFERENCES patients(id) ON DELETE CASCADE,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
ok INTEGER NOT NULL,
error TEXT NOT NULL DEFAULT '',
sent_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_mail_log_summary ON mail_log(summary_id);
`},
}
▾Ainternal/mailer/mailer.go
@@ -0,0 +1,152 @@
// Package mailer verschickt E-Mails (Text + HTML) über SMTP, nur mit der Standardbibliothek.
package mailer
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
"forge.gpl-nas.de/physiobase/internal/config"
)
type Message struct {
To, Subject, Text, HTML string
FromName string
}
// ValidAddress prüft eine einzelne Empfängeradresse (keine Listen, keine Header-Injektion).
func ValidAddress(s string) bool {
if strings.ContainsAny(s, "\r\n,;") {
return false
}
a, err := mail.ParseAddress(s)
return err == nil && a.Address == s
}
// Build erzeugt die komplette MIME-Nachricht (multipart/alternative).
func Build(cfg config.SMTP, m Message, now time.Time) ([]byte, error) {
if !ValidAddress(m.To) {
return nil, fmt.Errorf("ungültige Empfängeradresse")
}
from := (&mail.Address{Name: m.FromName, Address: cfg.From}).String()
domain := cfg.From[strings.LastIndex(cfg.From, "@")+1:]
id := make([]byte, 12)
if _, err := rand.Read(id); err != nil {
return nil, err
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
for _, part := range []struct{ ctype, content string }{{"text/plain", m.Text}, {"text/html", m.HTML}} {
if part.content == "" {
continue
}
pw, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {part.ctype + "; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return nil, err
}
qp := quotedprintable.NewWriter(pw)
if _, err := qp.Write([]byte(part.content)); err != nil {
return nil, err
}
if err := qp.Close(); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
var msg bytes.Buffer
h := func(k, v string) { fmt.Fprintf(&msg, "%s: %s\r\n", k, v) }
h("From", from)
h("To", m.To)
h("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
h("Date", now.Format(time.RFC1123Z))
h("Message-ID", "<"+hex.EncodeToString(id)+"@"+domain+">")
h("MIME-Version", "1.0")
h("Content-Type", "multipart/alternative; boundary="+mw.Boundary())
msg.WriteString("\r\n")
msg.Write(body.Bytes())
return msg.Bytes(), nil
}
// Send verbindet sich mit dem SMTP-Server und liefert die Nachricht aus.
func Send(ctx context.Context, cfg config.SMTP, m Message) error {
if !cfg.Enabled() {
return errors.New("E-Mail-Versand ist nicht eingerichtet (PHYSIOBASE_SMTP_*)")
}
raw, err := Build(cfg, m, time.Now())
if err != nil {
return err
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
d := &net.Dialer{}
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
var conn net.Conn
if cfg.Security == "ssl" {
conn, err = (&tls.Dialer{NetDialer: d, Config: tlsCfg}).DialContext(ctx, "tcp", addr)
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return fmt.Errorf("verbindung zu %s: %w", addr, err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
_ = conn.Close()
return err
}
defer func() { _ = c.Close() }()
if cfg.Security == "starttls" {
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if cfg.User != "" {
// PlainAuth verweigert unverschlüsselte Verbindungen außer zu localhost.
if err := c.Auth(smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("anmeldung: %w", err)
}
}
if err := c.Mail(cfg.From); err != nil {
return fmt.Errorf("absender: %w", err)
}
if err := c.Rcpt(m.To); err != nil {
return fmt.Errorf("empfänger: %w", err)
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("versand: %w", err)
}
return c.Quit()
}
▾Ainternal/store/docs.go
@@ -0,0 +1,78 @@
package store
import (
"context"
"time"
)
// Doc ist eine eingescannte oder fotografierte Unterlage eines Patienten.
type Doc struct {
ID, PatientID int64
Filename, Original, Title, Category string
Date string // YYYY-MM-DD
Size int64
Created time.Time
}
// DocCategories für die Auswahlliste; freie Eingabe bleibt möglich.
var DocCategories = []string{
"Ärztliche Verordnung", "Arztbrief / Befund", "OP-Bericht", "Bildgebung (Röntgen, MRT …)",
"Entlassungsbericht", "Einwilligung / Formular", "Foto (Verlauf, Wunde, Haltung)", "Sonstiges",
}
func (s *Store) Docs(ctx context.Context, pid int64) ([]Doc, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, patient_id, filename, original, title, category, date, size, created_at
FROM patient_docs WHERE patient_id = ? ORDER BY date DESC, id DESC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Doc
for rows.Next() {
var d Doc
var c int64
if err := rows.Scan(&d.ID, &d.PatientID, &d.Filename, &d.Original, &d.Title, &d.Category, &d.Date, &d.Size, &c); err != nil {
return nil, err
}
d.Created = time.Unix(c, 0)
out = append(out, d)
}
return out, rows.Err()
}
func (s *Store) AddDoc(ctx context.Context, d Doc) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO patient_docs (patient_id, filename, original, title, category, date, size)
VALUES (?, ?, ?, ?, ?, ?, ?)`, d.PatientID, d.Filename, d.Original, d.Title, d.Category, d.Date, d.Size)
if err == nil {
s.touchPatient(ctx, d.PatientID)
}
return err
}
func (s *Store) DeleteDoc(ctx context.Context, id int64) (Doc, error) {
var d Doc
err := s.db.QueryRowContext(ctx, `DELETE FROM patient_docs WHERE id = ? RETURNING id, patient_id, filename`, id).
Scan(&d.ID, &d.PatientID, &d.Filename)
return d, notFound(err)
}
// DocByFile liefert die Unterlage zu einem gespeicherten Dateinamen (nur bekannte Dateien werden ausgeliefert).
func (s *Store) DocByFile(ctx context.Context, filename string) (Doc, error) {
var d Doc
err := s.db.QueryRowContext(ctx, `SELECT id, patient_id, filename, original, title FROM patient_docs WHERE filename = ?`, filename).
Scan(&d.ID, &d.PatientID, &d.Filename, &d.Original, &d.Title)
return d, notFound(err)
}
// DocFiles: alle Dateinamen eines Patienten, damit sie beim Löschen des Patienten mit entfernt werden.
func (s *Store) DocFiles(ctx context.Context, pid int64) ([]string, error) {
docs, err := s.Docs(ctx, pid)
if err != nil {
return nil, err
}
files := make([]string, len(docs))
for i, d := range docs {
files[i] = d.Filename
}
return files, nil
}
▾Minternal/store/patients.go
@@ -11,6 +11,8 @@ import (
type Patient struct {
ID int64
Name, Dob, Phone, Notes string
Email string
EmailConsent bool // Einwilligung in den Versand von Gesundheitsinformationen per E-Mail
Archived bool
Created, Updated time.Time
Befunde, Sessions int
@@ -32,7 +34,7 @@ type Session struct {
// ---------- Patienten ----------
func (s *Store) Patients(ctx context.Context, q string, archived bool) ([]Patient, error) {
rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.name, p.dob, p.phone, p.notes, p.archived, p.created_at, p.updated_at,
rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.name, p.dob, p.phone, p.notes, p.email, p.email_consent, p.archived, p.created_at, p.updated_at,
(SELECT count(*) FROM befunde b WHERE b.patient_id = p.id),
(SELECT count(*) FROM sessions x WHERE x.patient_id = p.id),
COALESCE(max((SELECT max(date) FROM befunde b WHERE b.patient_id = p.id),
@@ -59,7 +61,7 @@ func scanPatient(sc scanner, withStats bool) (Patient, error) {
var p Patient
var c, u int64
var last sql.NullString
dest := []any{&p.ID, &p.Name, &p.Dob, &p.Phone, &p.Notes, &p.Archived, &c, &u}
dest := []any{&p.ID, &p.Name, &p.Dob, &p.Phone, &p.Notes, &p.Email, &p.EmailConsent, &p.Archived, &c, &u}
if withStats {
dest = append(dest, &p.Befunde, &p.Sessions, &last)
}
@@ -69,13 +71,14 @@ func scanPatient(sc scanner, withStats bool) (Patient, error) {
}
func (s *Store) Patient(ctx context.Context, id int64) (Patient, error) {
p, err := scanPatient(s.db.QueryRowContext(ctx, `SELECT id, name, dob, phone, notes, archived, created_at, updated_at
p, err := scanPatient(s.db.QueryRowContext(ctx, `SELECT id, name, dob, phone, notes, email, email_consent, archived, created_at, updated_at
FROM patients WHERE id = ?`, id), false)
return p, notFound(err)
}
func (s *Store) CreatePatient(ctx context.Context, p Patient) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO patients (name, dob, phone, notes) VALUES (?, ?, ?, ?)`, p.Name, p.Dob, p.Phone, p.Notes)
res, err := s.db.ExecContext(ctx, `INSERT INTO patients (name, dob, phone, notes, email, email_consent) VALUES (?, ?, ?, ?, ?, ?)`,
p.Name, p.Dob, p.Phone, p.Notes, p.Email, p.EmailConsent)
if err != nil {
return 0, err
}
@@ -83,8 +86,8 @@ func (s *Store) CreatePatient(ctx context.Context, p Patient) (int64, error) {
}
func (s *Store) UpdatePatient(ctx context.Context, p Patient) error {
res, err := s.db.ExecContext(ctx, `UPDATE patients SET name=?, dob=?, phone=?, notes=?, updated_at=unixepoch() WHERE id=?`,
p.Name, p.Dob, p.Phone, p.Notes, p.ID)
res, err := s.db.ExecContext(ctx, `UPDATE patients SET name=?, dob=?, phone=?, notes=?, email=?, email_consent=?,
updated_at=unixepoch() WHERE id=?`, p.Name, p.Dob, p.Phone, p.Notes, p.Email, p.EmailConsent, p.ID)
if err != nil {
return err
}
▾Ainternal/store/summaries.go
@@ -0,0 +1,119 @@
package store
import (
"context"
"database/sql"
"time"
)
// Summary ist eine Zusammenfassung zum Mitgeben: Kind "gesamt" oder "sitzung".
type Summary struct {
ID, PatientID int64
SessionID int64 // 0 = keine
Kind, Date, Title, Body string
Created, Updated time.Time
}
const summaryCols = `id, patient_id, session_id, kind, date, title, body, created_at, updated_at`
func scanSummary(sc scanner) (Summary, error) {
var x Summary
var sid sql.NullInt64
var c, u int64
err := sc.Scan(&x.ID, &x.PatientID, &sid, &x.Kind, &x.Date, &x.Title, &x.Body, &c, &u)
x.SessionID, x.Created, x.Updated = sid.Int64, time.Unix(c, 0), time.Unix(u, 0)
return x, err
}
func (s *Store) Summaries(ctx context.Context, pid int64) ([]Summary, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+summaryCols+` FROM summaries WHERE patient_id = ? ORDER BY date DESC, id DESC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Summary
for rows.Next() {
x, err := scanSummary(rows)
if err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) Summary(ctx context.Context, id int64) (Summary, error) {
x, err := scanSummary(s.db.QueryRowContext(ctx, `SELECT `+summaryCols+` FROM summaries WHERE id = ?`, id))
return x, notFound(err)
}
func (s *Store) CreateSummary(ctx context.Context, x Summary) (int64, error) {
var sid any
if x.SessionID > 0 {
sid = x.SessionID
}
res, err := s.db.ExecContext(ctx, `INSERT INTO summaries (patient_id, session_id, kind, date, title, body) VALUES (?, ?, ?, ?, ?, ?)`,
x.PatientID, sid, x.Kind, x.Date, x.Title, x.Body)
if err != nil {
return 0, err
}
s.touchPatient(ctx, x.PatientID)
return res.LastInsertId()
}
func (s *Store) UpdateSummary(ctx context.Context, x Summary) error {
res, err := s.db.ExecContext(ctx, `UPDATE summaries SET date=?, title=?, body=?, updated_at=unixepoch() WHERE id=?`,
x.Date, x.Title, x.Body, x.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
var pid int64
err := s.db.QueryRowContext(ctx, `DELETE FROM summaries WHERE id = ? RETURNING patient_id`, id).Scan(&pid)
return pid, notFound(err)
}
func (s *Store) Session(ctx context.Context, id int64) (Session, error) {
var x Session
err := s.db.QueryRowContext(ctx, `SELECT id, patient_id, date, goal, note FROM sessions WHERE id = ?`, id).
Scan(&x.ID, &x.PatientID, &x.Date, &x.Goal, &x.Note)
return x, notFound(err)
}
// MailLog ist ein Eintrag im Versandprotokoll.
type MailLog struct {
Recipient, Subject, Error string
OK bool
Sent time.Time
}
func (s *Store) LogMail(ctx context.Context, summaryID, patientID int64, l MailLog) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO mail_log (summary_id, patient_id, recipient, subject, ok, error) VALUES (?, ?, ?, ?, ?, ?)`,
summaryID, patientID, l.Recipient, l.Subject, l.OK, l.Error)
return err
}
func (s *Store) MailLogFor(ctx context.Context, summaryID int64) ([]MailLog, error) {
rows, err := s.db.QueryContext(ctx, `SELECT recipient, subject, ok, error, sent_at FROM mail_log WHERE summary_id = ? ORDER BY id DESC`, summaryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailLog
for rows.Next() {
var l MailLog
var t int64
if err := rows.Scan(&l.Recipient, &l.Subject, &l.OK, &l.Error, &t); err != nil {
return nil, err
}
l.Sent = time.Unix(t, 0)
out = append(out, l)
}
return out, rows.Err()
}
▾Ainternal/web/docs.go
@@ -0,0 +1,117 @@
package web
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"forge.gpl-nas.de/physiobase/internal/store"
)
const maxDocUpload = 300 << 20 // 300 MB pro Upload (mehrere Scans auf einmal)
// Scans und Fotos. HEIC/TIFF werden gespeichert, aber vom Browser meist nicht angezeigt (→ Download).
var docExt = map[string]bool{
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
".heic": true, ".heif": true, ".tif": true, ".tiff": true,
}
func isPreviewable(f string) bool { return isImage(f) }
func (s *Server) uploadDocs(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.st.Patient(ctx, pid); err != nil {
s.fail(w, r, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxDocUpload)
if err := r.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt
http.Error(w, "Upload fehlgeschlagen (max. 300 MB auf einmal)", http.StatusBadRequest)
return
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
files := r.MultipartForm.File["files"]
if len(files) == 0 {
http.Error(w, "Keine Datei ausgewählt", http.StatusBadRequest)
return
}
title, category, date := formText(r, "title"), formText(r, "category"), validDate(formText(r, "date"))
for i, fh := range files {
ext := extOf(fh.Filename)
if !docExt[ext] {
http.Error(w, "Dateityp nicht erlaubt: "+fh.Filename+" (PDF oder Foto)", http.StatusBadRequest)
return
}
name := fmt.Sprintf("%d-%s%s", pid, randomHex(10), ext)
if err := saveUpload(s.cfg.DocsDir, fh.Open, name); err != nil {
s.fail(w, r, err)
return
}
t := title
switch {
case t == "":
t = strings.TrimSuffix(filepath.Base(fh.Filename), filepath.Ext(fh.Filename))
case len(files) > 1:
t = fmt.Sprintf("%s (%d/%d)", title, i+1, len(files))
}
d := store.Doc{PatientID: pid, Filename: name, Original: filepath.Base(fh.Filename), Title: t, Category: category, Date: date, Size: fh.Size}
if err := s.st.AddDoc(ctx, d); err != nil {
os.Remove(filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
s.fail(w, r, err)
return
}
}
redirect(w, r, "/patient/%d#dokumente", pid)
}
func (s *Server) deleteDoc(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
d, err := s.st.DeleteDoc(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
s.removeDocFile(d.Filename)
redirect(w, r, "/patient/%d#dokumente", d.PatientID)
}
func (s *Server) removeDocFile(name string) {
err := os.Remove(filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name stammt aus der Datenbank
if err != nil && !errors.Is(err, os.ErrNotExist) {
s.log.Warn("dokument nicht gelöscht", "datei", name, "err", err)
}
}
func (s *Server) serveDoc(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
if name != filepath.Base(name) || strings.HasPrefix(name, ".") {
http.NotFound(w, r)
return
}
d, err := s.st.DocByFile(r.Context(), name)
if err != nil {
s.fail(w, r, err)
return
}
// Gesundheitsdaten: nicht in geteilten Caches ablegen
w.Header().Set("Cache-Control", "private, no-store")
disposition := "inline"
if !isPreviewable(name) && extOf(name) != ".pdf" {
disposition = "attachment" // HEIC/TIFF kann der Browser meist nicht anzeigen
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename*=UTF-8''%s`, disposition, urlPathEscape(d.Original)))
http.ServeFile(w, r, filepath.Join(s.cfg.DocsDir, name)) //nolint:gosec // G703: Name geprüft und in der DB bekannt
}
▾Ainternal/web/features_test.go
@@ -0,0 +1,231 @@
package web
import (
"bufio"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
"sync"
"testing"
"forge.gpl-nas.de/physiobase/internal/config"
)
func login(t *testing.T, c *http.Client, base string) {
t.Helper()
r, err := post(c, base+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}})
if err != nil || r.StatusCode != http.StatusSeeOther {
t.Fatalf("login: %v", err)
}
}
func TestPrintable(t *testing.T) {
in := "✎ Hinweis oben\n\n## Ihre Beschwerden\n\nKnie tut weh.\n✎ nur für mich\n\n## Leer\n\n✎ nichts\n\n## Übungen\n\n- Wandsitz\n"
got := Printable(in)
if strings.Contains(got, "✎") || strings.Contains(got, "## Leer") {
t.Errorf("Hinweise oder leerer Abschnitt im Druck:\n%s", got)
}
if !strings.Contains(got, "## Ihre Beschwerden") || !strings.Contains(got, "- Wandsitz") {
t.Errorf("Inhalt fehlt:\n%s", got)
}
}
func TestPatientDocs(t *testing.T) {
ts, c := newServer(t)
login(t, c, ts.URL)
post(c, ts.URL+"/patienten", url.Values{"name": {"Doku Test"}})
upload := func(name, content string) int {
var buf strings.Builder
w := multipart.NewWriter(&buf)
fw, _ := w.CreateFormFile("files", name)
io.WriteString(fw, content)
w.WriteField("category", "Arztbrief / Befund")
w.WriteField("date", "2026-10-05")
w.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/patient/1/dokumente", strings.NewReader(buf.String()))
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
r, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
r.Body.Close()
return r.StatusCode
}
if code := upload("Arztbrief Müller.pdf", "%PDF-1.4 test"); code != http.StatusSeeOther {
t.Fatalf("PDF-Upload: %d", code)
}
if code := upload("boese.exe", "MZ"); code != http.StatusBadRequest {
t.Fatalf("exe akzeptiert: %d", code)
}
_, html := body(t, c, ts.URL+"/patient/1")
i := strings.Index(html, "/dokumente/1-")
if i < 0 || !strings.Contains(html, "Arztbrief Müller") || !strings.Contains(html, "05.10.2026") {
t.Fatal("Dokument nicht auf der Patientenseite")
}
link := html[i : i+strings.IndexByte(html[i:], '"')]
r, _ := c.Get(ts.URL + link)
b, _ := io.ReadAll(r.Body)
r.Body.Close()
if r.StatusCode != http.StatusOK || string(b) != "%PDF-1.4 test" || !strings.Contains(r.Header.Get("Cache-Control"), "no-store") {
t.Fatalf("Dokument abrufen: %d %q", r.StatusCode, r.Header.Get("Cache-Control"))
}
// Abgemeldet kein Zugriff
anon := &http.Client{CheckRedirect: c.CheckRedirect}
if r, _ := anon.Get(ts.URL + link); r.StatusCode != http.StatusSeeOther {
t.Fatalf("ohne Login: %d", r.StatusCode)
}
// Patient löschen entfernt auch die Datei
post(c, ts.URL+"/patient/1/loeschen", nil)
if code, _ := body(t, c, ts.URL+link); code != http.StatusNotFound {
t.Errorf("Dokument nach Löschen noch da: %d", code)
}
}
func TestSummaries(t *testing.T) {
ts, c := newServer(t)
login(t, c, ts.URL)
post(c, ts.URL+"/patienten", url.Values{"name": {"Sam Beispiel"}})
post(c, ts.URL+"/patient/1/befund", url.Values{})
post(c, ts.URL+"/befund/1", url.Values{
"title": {"Erstbefund"}, "date": {"2026-10-01"}, "f.diag": {"Patellatendinopathie"},
"f.pain_now": {"6"}, "f.goal_patient": {"Wieder Basketball spielen"}, "t.psfs.n": {"1"}, "t.psfs.0.activity": {"Treppe"}, "t.psfs.0.score": {"4"},
})
r, _ := post(c, ts.URL+"/patient/1/handout", url.Values{"title": {"Woche 1"}})
hid := strings.TrimPrefix(r.Header.Get("Location"), "/handout/")
post(c, ts.URL+"/handout/"+hid+"/uebung", url.Values{"entry": {"3"}}) // Wandsitz aus den Beispieldaten
r, _ = post(c, ts.URL+"/patient/1/zusammenfassung", nil)
loc := r.Header.Get("Location")
if !strings.HasPrefix(loc, "/zusammenfassung/") {
t.Fatalf("Gesamtzusammenfassung: %s", loc)
}
_, html := body(t, c, ts.URL+loc)
for _, want := range []string{"Patellatendinopathie", "Schmerz heute: 6 von 10", "Wieder Basketball spielen", "Treppe (heute 4 von 10", "Wandsitz", "Wann Sie sich melden"} {
if !strings.Contains(html, want) {
t.Errorf("Entwurf ohne %q", want)
}
}
_, pr := body(t, c, ts.URL+loc+"/druck")
if strings.Contains(pr, "✎") || !strings.Contains(pr, "Wandsitz") {
t.Error("Druck enthält Hinweise oder keine Übungen")
}
// Behandlungszusammenfassung
post(c, ts.URL+"/patient/1/verlauf", url.Values{"date": {"2026-10-08"}, "goal": {"Kniestreckung kräftigen, Hüpfen dosiert"}})
r, _ = post(c, ts.URL+"/verlauf/1/zusammenfassung", nil)
_, html = body(t, c, ts.URL+r.Header.Get("Location"))
if !strings.Contains(html, "Ihre Behandlung am 08.10.2026") || !strings.Contains(html, "Kniestreckung kräftigen") {
t.Error("Behandlungszusammenfassung unvollständig")
}
// Ohne SMTP: Senden wird verhindert, mit Begründung
_, html = body(t, c, ts.URL+loc)
if !strings.Contains(html, "nicht eingerichtet") {
t.Error("fehlender Hinweis auf SMTP")
}
}
// fakeSMTP nimmt genau eine Nachricht an (ohne TLS/Anmeldung) und gibt sie zurück.
func fakeSMTP(t *testing.T) (string, func() string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
var mu sync.Mutex
var got strings.Builder
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
rd := bufio.NewReader(conn)
say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) }
say("220 fake")
inData := false
for {
line, err := rd.ReadString('\n')
if err != nil {
return
}
if inData {
if line == ".\r\n" {
inData = false
say("250 ok")
continue
}
mu.Lock()
got.WriteString(line)
mu.Unlock()
continue
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
say("250 fake")
case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"):
mu.Lock()
got.WriteString(line)
mu.Unlock()
say("250 ok")
case cmd == "DATA":
inData = true
say("354 go")
case cmd == "QUIT":
say("221 bye")
return
default:
say("250 ok")
}
}
}()
return ln.Addr().String(), func() string { mu.Lock(); defer mu.Unlock(); return got.String() }
}
func TestSendSummary(t *testing.T) {
addr, received := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
var p int
fmt.Sscan(port, &p)
ts, c := newServerCfg(t, func(cfg *config.Config) {
cfg.SMTP = config.SMTP{Host: host, Port: p, Security: "none", From: "praxis@example.org"}
})
login(t, c, ts.URL)
post(c, ts.URL+"/patienten", url.Values{"name": {"Mia Mail"}, "email": {"mia@example.org"}})
r, _ := post(c, ts.URL+"/patient/1/zusammenfassung", nil)
loc := r.Header.Get("Location")
// ohne Einwilligung: kein Versand
r, _ = post(c, ts.URL+loc+"/senden", nil)
if !strings.Contains(r.Header.Get("Location"), "fehler=") {
t.Fatal("Versand ohne Einwilligung nicht verhindert")
}
post(c, ts.URL+"/patient/1", url.Values{"name": {"Mia Mail"}, "email": {"mia@example.org"}, "email_consent": {"1"}})
post(c, ts.URL+loc, url.Values{"title": {"Ihre Physiotherapie"}, "date": {"2026-11-01"}, "body": {"## Ihre Übungen\n\n- Wandsitz\n✎ geheim"}})
r, _ = post(c, ts.URL+loc+"/senden", nil)
if !strings.Contains(r.Header.Get("Location"), "gesendet=1") {
t.Fatalf("Versand: %s", r.Header.Get("Location"))
}
msg := received()
for _, want := range []string{"RCPT TO:<mia@example.org>", "Subject: Ihre Physiotherapie", "Guten Tag Mia Mail", "Wandsitz", "multipart/alternative"} {
if !strings.Contains(msg, want) {
t.Errorf("Mail ohne %q", want)
}
}
if strings.Contains(msg, "geheim") {
t.Error("✎-Hinweis wurde mitgeschickt")
}
if _, html := body(t, c, ts.URL+loc); !strings.Contains(html, "✓") {
t.Error("Versandprotokoll fehlt")
}
// Header-Injektion in der Adresse wird abgelehnt
r, _ = post(c, ts.URL+"/patient/1", url.Values{"name": {"X"}, "email": {"a@b.de\r\nBcc: evil@x.de"}})
if r.StatusCode != http.StatusBadRequest {
t.Errorf("Injektion akzeptiert: %d", r.StatusCode)
}
}
▾Ainternal/web/mail.go
@@ -0,0 +1,91 @@
package web
import (
"bytes"
"html/template"
"net/http"
"net/url"
"strings"
"forge.gpl-nas.de/physiobase/internal/mailer"
"forge.gpl-nas.de/physiobase/internal/store"
)
// E-Mail-Clients ignorieren externe CSS-Dateien → schlichte Inline-Stile.
var mailHTML = template.Must(template.New("mail").Parse(`<!doctype html>
<html lang="de"><head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body style="margin:0;padding:0;background:#f4f6f8;">
<div style="max-width:640px;margin:0 auto;padding:24px;font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif;font-size:16px;line-height:1.55;color:#1d2a33;background:#ffffff;">
<h1 style="font-size:22px;margin:0 0 4px;color:#16a085;">{{.Title}}</h1>
<p style="margin:0 0 18px;color:#5c6b75;font-size:14px;">{{.Date}}</p>
<p>Guten Tag {{.Name}},</p>
<p>hier ist Ihre Zusammenfassung zum Nachlesen.</p>
<div>{{.Body}}</div>
<p style="margin-top:24px;">Viele Grüße<br>{{.Practice}}</p>
<p style="margin-top:24px;font-size:12px;color:#8a979f;">Diese Nachricht enthält persönliche Gesundheitsinformationen und ist nur für {{.Name}} bestimmt.</p>
</div></body></html>`))
func mailText(name, title, date, body, practice string) string {
var b strings.Builder
b.WriteString("Guten Tag " + name + ",\n\nhier ist Ihre Zusammenfassung zum Nachlesen.\n\n")
b.WriteString(title + " (" + date + ")\n" + strings.Repeat("=", len([]rune(title))) + "\n\n")
b.WriteString(body + "\n\nViele Grüße\n" + practice + "\n\n")
b.WriteString("Diese Nachricht enthält persönliche Gesundheitsinformationen und ist nur für " + name + " bestimmt.\n")
return b.String()
}
// mailBlockReason: warum (noch) nicht gesendet werden kann; leer = alles bereit.
func (s *Server) mailBlockReason(p store.Patient) string {
switch {
case !s.cfg.SMTP.Enabled():
return "Der E-Mail-Versand ist nicht eingerichtet (PHYSIOBASE_SMTP_* in .env)."
case p.Email == "":
return "Für diesen Patienten ist keine E-Mail-Adresse hinterlegt."
case !mailer.ValidAddress(p.Email):
return "Die hinterlegte E-Mail-Adresse ist ungültig."
case !p.EmailConsent:
return "Es fehlt die Einwilligung in den Versand per E-Mail (Stammdaten des Patienten)."
}
return ""
}
func (s *Server) sendSummary(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
x, p, ok := s.loadSummary(w, r)
if !ok {
return
}
if reason := s.mailBlockReason(p); reason != "" {
redirect(w, r, "/zusammenfassung/%d?fehler=%s#senden", x.ID, url.QueryEscape(reason))
return
}
body := Printable(x.Body)
var html bytes.Buffer
if err := mailHTML.Execute(&html, map[string]any{
"Title": x.Title, "Date": deDate(x.Date), "Name": p.Name, "Practice": s.cfg.PracticeName,
"Body": funcs["md"].(func(string) template.HTML)(body),
}); err != nil {
s.fail(w, r, err)
return
}
msg := mailer.Message{
To: p.Email, Subject: x.Title, FromName: s.cfg.PracticeName,
Text: mailText(p.Name, x.Title, deDate(x.Date), body, s.cfg.PracticeName), HTML: html.String(),
}
err := mailer.Send(ctx, s.cfg.SMTP, msg)
entry := store.MailLog{Recipient: p.Email, Subject: x.Title, OK: err == nil}
if err != nil {
entry.Error = err.Error()
s.log.Error("e-mail fehlgeschlagen", "zusammenfassung", x.ID, "err", err)
} else {
s.log.Info("e-mail gesendet", "zusammenfassung", x.ID, "patient", p.ID)
}
if lerr := s.st.LogMail(ctx, x.ID, p.ID, entry); lerr != nil {
s.log.Error("versandprotokoll", "err", lerr)
}
if err != nil {
redirect(w, r, "/zusammenfassung/%d?fehler=%s#senden", x.ID, url.QueryEscape("Senden fehlgeschlagen: "+err.Error()))
return
}
redirect(w, r, "/zusammenfassung/%d?gesendet=1#senden", x.ID)
}
▾Minternal/web/media.go
@@ -54,7 +54,7 @@ func (s *Server) uploadMedia(w http.ResponseWriter, r *http.Request) {
return
}
name := fmt.Sprintf("%d-%s%s", id, randomHex(8), extOf(fh.Filename))
if err := s.saveUpload(fh.Open, name); err != nil {
if err := saveUpload(s.cfg.MediaDir, fh.Open, name); err != nil {
s.fail(w, r, err)
return
}
@@ -73,13 +73,16 @@ func (s *Server) uploadMedia(w http.ResponseWriter, r *http.Request) {
// saveUpload schreibt erst in eine Temp-Datei und benennt dann um, damit nie
// halbe Dateien unter dem endgültigen Namen liegen.
func (s *Server) saveUpload(open func() (multipart.File, error), name string) error {
func saveUpload(dir string, open func() (multipart.File, error), name string) error {
src, err := open()
if err != nil {
return err
}
defer func() { _ = src.Close() }()
tmp, err := os.CreateTemp(s.cfg.MediaDir, ".upload-*")
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".upload-*")
if err != nil {
return err
}
@@ -93,7 +96,7 @@ func (s *Server) saveUpload(open func() (multipart.File, error), name string) er
return err
}
// CreateTemp legt die Datei mit 0600 an; ausgeliefert wird sie nur über die App.
return os.Rename(tmp.Name(), filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
return os.Rename(tmp.Name(), filepath.Join(dir, name)) //nolint:gosec // G703: Name vom Server erzeugt
}
func (s *Server) deleteMedia(w http.ResponseWriter, r *http.Request) {
▾Minternal/web/patients.go
@@ -10,6 +10,7 @@ import (
"time"
"forge.gpl-nas.de/physiobase/internal/befund"
"forge.gpl-nas.de/physiobase/internal/mailer"
"forge.gpl-nas.de/physiobase/internal/store"
)
@@ -41,11 +42,18 @@ func (s *Server) createPatient(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
p := store.Patient{Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone")}
p := store.Patient{
Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone"),
Email: formText(r, "email"), EmailConsent: r.FormValue("email_consent") == "1",
}
if p.Name == "" {
http.Error(w, "Name fehlt", http.StatusBadRequest)
return
}
if p.Email != "" && !mailer.ValidAddress(p.Email) {
http.Error(w, "Ungültige E-Mail-Adresse", http.StatusBadRequest)
return
}
id, err := s.st.CreatePatient(r.Context(), p)
if err != nil {
s.fail(w, r, err)
@@ -93,6 +101,16 @@ func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
docs, err := s.st.Docs(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
summaries, err := s.st.Summaries(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
// Verlauf der Kernwerte über alle Befunde (älteste zuerst)
type trendRow struct {
Date, Title string
@@ -111,6 +129,7 @@ func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "patient", http.StatusOK, map[string]any{
"P": p, "Befunde": bs, "Sessions": sessions, "Linked": linked, "Linkable": linkable,
"Handouts": handouts, "Trend": trend, "Today": time.Now().Format("2006-01-02"),
"Docs": docs, "DocCategories": store.DocCategories, "Summaries": summaries,
})
}
@@ -123,11 +142,18 @@ func (s *Server) updatePatient(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
p := store.Patient{ID: id, Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone"), Notes: formText(r, "notes")}
p := store.Patient{
ID: id, Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone"), Notes: formText(r, "notes"),
Email: formText(r, "email"), EmailConsent: r.FormValue("email_consent") == "1",
}
if p.Name == "" {
http.Error(w, "Name fehlt", http.StatusBadRequest)
return
}
if p.Email != "" && !mailer.ValidAddress(p.Email) {
http.Error(w, "Ungültige E-Mail-Adresse", http.StatusBadRequest)
return
}
if err := s.st.UpdatePatient(r.Context(), p); err != nil {
s.fail(w, r, err)
return
@@ -154,10 +180,18 @@ func (s *Server) deletePatient(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
files, err := s.st.DocFiles(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
if err := s.st.DeletePatient(r.Context(), id); err != nil {
s.fail(w, r, err)
return
}
for _, f := range files {
s.removeDocFile(f)
}
redirect(w, r, "/patienten")
}
▾Minternal/web/server.go
@@ -10,6 +10,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@@ -40,14 +41,14 @@ func New(ctx context.Context, cfg *config.Config, st *store.Store, log *slog.Log
if err := s.loadSessionSecret(ctx); err != nil {
return nil, err
}
for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare"} {
for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare", "summary"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/layout.html", "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
}
s.pages[p] = t
}
for _, p := range []string{"print", "befund_print"} {
for _, p := range []string{"print", "befund_print", "summary_print"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
@@ -109,6 +110,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /patient/{id}/wissen/{eid}/loeschen", s.unlinkPatientEntry)
mux.HandleFunc("POST /patient/{id}/handout", s.createPatientHandout)
mux.HandleFunc("GET /patient/{id}/vergleich", s.compareBefunde)
mux.HandleFunc("POST /patient/{id}/dokumente", s.uploadDocs)
mux.HandleFunc("POST /dokument/{id}/loeschen", s.deleteDoc)
mux.HandleFunc("GET /dokumente/{file}", s.serveDoc)
mux.HandleFunc("POST /patient/{id}/zusammenfassung", s.createGeneralSummary)
mux.HandleFunc("POST /verlauf/{id}/zusammenfassung", s.createSessionSummary)
mux.HandleFunc("GET /zusammenfassung/{id}", s.showSummary)
mux.HandleFunc("POST /zusammenfassung/{id}", s.saveSummary)
mux.HandleFunc("POST /zusammenfassung/{id}/loeschen", s.deleteSummary)
mux.HandleFunc("GET /zusammenfassung/{id}/druck", s.printSummary)
mux.HandleFunc("POST /zusammenfassung/{id}/senden", s.sendSummary)
mux.HandleFunc("GET /befund/{id}", s.showBefund)
mux.HandleFunc("POST /befund/{id}", s.saveBefund)
mux.HandleFunc("POST /befund/{id}/loeschen", s.deleteBefund)
@@ -199,6 +210,15 @@ var funcs = template.FuncMap{
"isImage": isImage,
"isVideo": isVideo,
"isPDF": func(f string) bool { return strings.EqualFold(extOf(f), ".pdf") },
"size": func(n int64) string {
switch {
case n >= 1<<20:
return strings.ReplaceAll(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".", ",") + " MB"
case n >= 1<<10:
return strconv.FormatInt(n>>10, 10) + " KB"
}
return strconv.FormatInt(n, 10) + " B"
},
"tags": func(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
@@ -230,7 +250,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, sta
data["Kinds"] = store.Kinds
data["Path"] = r.URL.Path
name := "layout"
if page == "print" || page == "befund_print" {
if page == "print" || page == "befund_print" || page == "summary_print" {
name = page
}
var buf bytes.Buffer
@@ -257,6 +277,9 @@ func redirect(w http.ResponseWriter, r *http.Request, format string, args ...any
http.Redirect(w, r, fmt.Sprintf(format, args...), http.StatusSeeOther) //nolint:gosec // G710
}
// urlPathEscape für Dateinamen in Content-Disposition (RFC 5987).
func urlPathEscape(s string) string { return url.PathEscape(s) }
func pathID(r *http.Request, name string) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
return id, err == nil && id > 0
▾Ainternal/web/summaries.go
@@ -0,0 +1,349 @@
package web
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"forge.gpl-nas.de/physiobase/internal/befund"
"forge.gpl-nas.de/physiobase/internal/store"
)
// Zeilen, die mit diesem Zeichen beginnen, sind Hinweise für die Therapeutin
// und erscheinen nicht im Ausdruck.
const noteMark = "✎"
// Printable entfernt Hinweiszeilen und daraus entstandene leere Abschnitte.
func Printable(body string) string {
var out []string
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), noteMark) {
continue
}
out = append(out, line)
}
// Überschriften ohne Inhalt bis zur nächsten Überschrift entfernen
var cleaned []string
for i := 0; i < len(out); i++ {
if strings.HasPrefix(out[i], "## ") {
j := i + 1
for j < len(out) && strings.TrimSpace(out[j]) == "" {
j++
}
if j == len(out) || strings.HasPrefix(out[j], "## ") {
i = j - 1
continue
}
}
cleaned = append(cleaned, out[i])
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
type draft struct{ b strings.Builder }
func (d *draft) section(title string) { fmt.Fprintf(&d.b, "\n## %s\n\n", title) }
func (d *draft) line(format string, a ...any) {
fmt.Fprintf(&d.b, format+"\n", a...)
}
func (d *draft) note(text string) { d.line("%s %s", noteMark, text) }
func (d *draft) String() string { return strings.TrimSpace(d.b.String()) + "\n" }
// latestHandoutItems: Übungen des zuletzt bearbeiteten Heimprogramms des Patienten.
func (s *Server) latestHandoutItems(ctx context.Context, pid int64) (store.Handout, []store.HandoutItem, error) {
hs, err := s.st.HandoutsForPatient(ctx, pid)
if err != nil || len(hs) == 0 {
return store.Handout{}, nil, err
}
items, err := s.st.HandoutItems(ctx, hs[0].ID)
return hs[0], items, err
}
func (d *draft) exercises(h store.Handout, items []store.HandoutItem) {
if len(items) == 0 {
d.line("- ")
d.note("Noch kein Heimprogramm – auf der Patientenseite unter „Heimprogramme“ anlegen.")
return
}
for _, it := range items {
if it.Dosage != "" {
d.line("- **%s** – %s", it.Entry.Title, it.Dosage)
} else {
d.line("- **%s**", it.Entry.Title)
}
}
d.line("")
d.line("Die genaue Anleitung mit Bildern finden Sie im Übungsblatt „%s“.", h.Title)
}
func safetyNet(d *draft) {
d.section("Wann Sie sich melden sollten")
d.line("- wenn die Beschwerden deutlich stärker werden oder nach Übungen länger als einen Tag schlechter bleiben")
d.line("- bei neuem Taubheitsgefühl, Kribbeln oder Kraftverlust")
d.line("- bei Schwellung, Rötung, Überwärmung oder Fieber")
d.note("Bei Rückenbeschwerden ggf. ergänzen: Taubheit im Gesäß-/Genitalbereich oder Probleme beim Wasserlassen → sofort ärztlich abklären.")
}
// generalDraft baut die Gesamtzusammenfassung aus dem neuesten Befund, dem
// Heimprogramm und dem Verlauf seit dem ersten Befund.
func (s *Server) generalDraft(ctx context.Context, p store.Patient) (string, error) {
bs, err := s.st.Befunde(ctx, p.ID)
if err != nil {
return "", err
}
h, items, err := s.latestHandoutItems(ctx, p.ID)
if err != nil {
return "", err
}
d := &draft{}
d.note("Entwurf aus den Befunddaten. In einfache Worte bringen; ✎-Zeilen werden nicht gedruckt.")
var last, first befund.Data
if len(bs) > 0 {
last, _ = befund.Parse(bs[0].Data)
first, _ = befund.Parse(bs[len(bs)-1].Data)
}
d.section("Ihre Beschwerden")
if diag := last.Get("diag"); diag != "" {
d.line("Ärztliche Diagnose: %s", diag)
d.line("")
}
d.line("")
d.note("In 2–3 Sätzen erklären, was los ist und warum. Fachbegriffe übersetzen.")
if h := last.Get("hypAfterExam"); h != "" {
d.note("Ihre Arbeitshypothese: " + oneLine(h))
}
if now, worst := last.Get("pain_now"), last.Get("pain_worst"); now != "" || worst != "" {
d.section("Was wir festgestellt haben")
if now != "" {
d.line("- Schmerz heute: %s von 10", now)
}
if worst != "" {
d.line("- Stärkster Schmerz in den letzten 24 Stunden: %s von 10", worst)
}
d.note("Wichtigste Befunde in Alltagssprache ergänzen (Beweglichkeit, Kraft …).")
}
d.section("Ihre Ziele")
for _, k := range []string{"goal_patient", "goal_agreement"} {
if v := last.Get(k); v != "" {
for _, l := range strings.Split(v, "\n") {
if l = strings.TrimSpace(strings.TrimLeft(l, "-•* ")); l != "" {
d.line("- %s", l)
}
}
}
}
for _, r := range last.Tables["psfs"] {
if r["activity"] != "" {
if r["score"] != "" {
d.line("- %s (heute %s von 10 möglich)", r["activity"], r["score"])
} else {
d.line("- %s", r["activity"])
}
}
}
d.note("Ziele so formulieren, wie der Patient sie selbst sagen würde.")
d.section("So gehen wir vor")
if plan := last.Get("plan"); plan != "" {
d.note("Therapieplan aus dem Befund: " + oneLine(plan))
}
d.line("- ")
d.note("Behandlungsschwerpunkte, Anzahl/Häufigkeit der Termine, wann wir neu messen.")
d.section("Ihr Übungsprogramm für zu Hause")
d.exercises(h, items)
if len(bs) > 1 {
d.section("Ihr Fortschritt")
if a, b := first.Get("pain_now"), last.Get("pain_now"); a != "" && b != "" {
d.line("- Schmerz: von %s auf %s (von 10)", a, b)
}
if a, b := befund.PSFSMean(first), befund.PSFSMean(last); a != "" && b != "" {
d.line("- Ihre Alltagsaktivitäten: von durchschnittlich %s auf %s (von 10)", a, b)
}
d.note("Was hat sich im Alltag verbessert? Konkrete Beispiele des Patienten aufgreifen.")
}
safetyNet(d)
return d.String(), nil
}
// sessionDraft baut die Zusammenfassung einer Behandlung.
func (s *Server) sessionDraft(ctx context.Context, x store.Session) (string, error) {
h, items, err := s.latestHandoutItems(ctx, x.PatientID)
if err != nil {
return "", err
}
d := &draft{}
d.note("Entwurf aus dem Verlaufseintrag. ✎-Zeilen werden nicht gedruckt.")
d.section("Das haben wir heute gemacht")
if x.Goal != "" {
d.line("- %s", x.Goal)
} else {
d.line("- ")
}
if x.Note != "" {
d.note("Ihre Notiz: " + oneLine(x.Note))
}
d.section("Ihre Übungen bis zum nächsten Termin")
d.exercises(h, items)
d.section("Darauf sollten Sie achten")
d.line("- ")
d.note("z. B. Belastung dosieren, Schmerz bei Übungen bis 3–4 von 10 ist in Ordnung, am Folgetag nicht schlechter.")
d.section("Nächster Termin")
d.line("- ")
return d.String(), nil
}
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
// ---------- Handler ----------
func (s *Server) createGeneralSummary(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p, err := s.st.Patient(ctx, pid)
if err != nil {
s.fail(w, r, err)
return
}
body, err := s.generalDraft(ctx, p)
if err != nil {
s.fail(w, r, err)
return
}
id, err := s.st.CreateSummary(ctx, store.Summary{
PatientID: pid, Kind: "gesamt",
Date: time.Now().Format("2006-01-02"), Title: "Ihre Physiotherapie – Zusammenfassung", Body: body,
})
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/zusammenfassung/%d", id)
}
func (s *Server) createSessionSummary(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
x, err := s.st.Session(ctx, sid)
if err != nil {
s.fail(w, r, err)
return
}
body, err := s.sessionDraft(ctx, x)
if err != nil {
s.fail(w, r, err)
return
}
id, err := s.st.CreateSummary(ctx, store.Summary{
PatientID: x.PatientID, SessionID: x.ID, Kind: "sitzung",
Date: x.Date, Title: "Ihre Behandlung am " + deDate(x.Date), Body: body,
})
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/zusammenfassung/%d", id)
}
func (s *Server) loadSummary(w http.ResponseWriter, r *http.Request) (store.Summary, store.Patient, bool) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return store.Summary{}, store.Patient{}, false
}
x, err := s.st.Summary(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return x, store.Patient{}, false
}
p, err := s.st.Patient(r.Context(), x.PatientID)
if err != nil {
s.fail(w, r, err)
return x, p, false
}
return x, p, true
}
func (s *Server) showSummary(w http.ResponseWriter, r *http.Request) {
x, p, ok := s.loadSummary(w, r)
if !ok {
return
}
log, err := s.st.MailLogFor(r.Context(), x.ID)
if err != nil {
s.fail(w, r, err)
return
}
q := r.URL.Query()
s.render(w, r, "summary", http.StatusOK, map[string]any{
"S": x, "P": p, "Preview": Printable(x.Body), "Saved": q.Get("gespeichert") == "1",
"MailBlock": s.mailBlockReason(p), "MailLog": log, "Sent": q.Get("gesendet") == "1", "MailError": q.Get("fehler"),
})
}
func (s *Server) saveSummary(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
x, _, ok := s.loadSummary(w, r)
if !ok {
return
}
x.Title, x.Date, x.Body = formText(r, "title"), validDate(formText(r, "date")), formText(r, "body")
if x.Title == "" {
x.Title = "Zusammenfassung"
}
if err := s.st.UpdateSummary(r.Context(), x); err != nil {
s.fail(w, r, err)
return
}
if r.FormValue("next") == "druck" {
redirect(w, r, "/zusammenfassung/%d/druck", x.ID)
return
}
redirect(w, r, "/zusammenfassung/%d?gespeichert=1", x.ID)
}
func (s *Server) deleteSummary(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
pid, err := s.st.DeleteSummary(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#zusammenfassungen", pid)
}
func (s *Server) printSummary(w http.ResponseWriter, r *http.Request) {
x, p, ok := s.loadSummary(w, r)
if !ok {
return
}
s.render(w, r, "summary_print", http.StatusOK, map[string]any{"S": x, "P": p, "Body": Printable(x.Body)})
}
func deDate(iso string) string {
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01.2006")
}
return iso
}
▾Minternal/web/web_test.go
@@ -20,6 +20,11 @@ import (
)
func newServer(t *testing.T) (*httptest.Server, *http.Client) {
t.Helper()
return newServerCfg(t, nil)
}
func newServerCfg(t *testing.T, mod func(*config.Config)) (*httptest.Server, *http.Client) {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "t.db"))
@@ -28,7 +33,10 @@ func newServer(t *testing.T) (*httptest.Server, *http.Client) {
}
t.Cleanup(func() { database.Close(db) })
hash, _ := HashPassword("geheim123")
cfg := &config.Config{Username: "admin", PasswordHash: hash, PracticeName: "Test", MediaDir: dir}
cfg := &config.Config{Username: "admin", PasswordHash: hash, PracticeName: "Test", MediaDir: dir, DocsDir: filepath.Join(dir, "docs")}
if mod != nil {
mod(cfg)
}
st := store.New(db)
if _, err := st.Seed(context.Background()); err != nil {
t.Fatal(err)
▾Mweb/static/print.css
@@ -22,3 +22,11 @@ h1 { font-size: 20pt; margin: 0; }
.note { font-style: italic; }
footer { margin-top: 1rem; font-size: 9.5pt; color: #555; border-top: 1px solid #ccc; padding-top: .4rem; }
@media print { .hint { display: none; } body { padding: 0; } }
/* Zusammenfassung für Patienten */
.summary .logo { height: 14mm; width: auto; margin-bottom: .2rem; }
.summary-body { font-size: 12.5pt; line-height: 1.55; }
.summary-body h2 { font-size: 14pt; color: #16a085; margin: 1.1rem 0 .3rem; padding-bottom: .15rem; border-bottom: 1px solid #cfe5df; break-after: avoid; }
.summary-body ul { margin: .2rem 0 .5rem; padding-left: 1.3rem; }
.summary-body li { margin: .15rem 0; }
.summary-body p { margin: .3rem 0; }
▾Mweb/static/style.css
@@ -197,3 +197,30 @@ table.etable th { font-size: .82em; padding: .3rem; }
.savebar button { flex: 1 1 auto; padding: .5rem .6rem; font-size: .95rem; }
.etable input, .etable select { min-width: 6.5rem; }
}
/* ---------- Patientendokumente ---------- */
.docs { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .8rem; margin-bottom: 1rem; }
.doc { margin: 0; }
.doc-preview { display: flex; align-items: center; justify-content: center; height: 8.5rem; overflow: hidden;
border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius);
background: var(--pico-code-background-color); text-decoration: none; }
.doc-preview img { width: 100%; height: 100%; object-fit: cover; }
.doc-icon { font-weight: 700; font-size: 1.3rem; color: var(--pico-del-color); padding: .5rem; text-align: center; word-break: break-all; }
.doc figcaption { font-size: .85em; margin-top: .3rem; display: flex; flex-direction: column; gap: .1rem; position: relative; padding-right: 1.4rem; }
.doc figcaption form { position: absolute; right: 0; top: 0; }
.doc-form { display: grid; grid-template-columns: 2fr 1.5fr 1.2fr 9.5rem auto; gap: .5rem; align-items: start; }
.doc-form > * { margin: 0; }
@media (max-width: 768px) { .doc-form { grid-template-columns: 1fr 1fr; } .doc-form input[type=file] { grid-column: 1 / -1; } }
@media (max-width: 576px) { .docs { grid-template-columns: repeat(2, 1fr); } .doc-preview { height: 7rem; } }
/* Zusammenfassungen */
.summaries { padding-left: 0; }
.summaries li { list-style: none; margin: .35rem 0; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; }
.small-link, button.small-link { font-size: .85em; }
.session-head { display: flex; gap: .5rem; align-items: baseline; flex-wrap: wrap; }
.session-actions { margin-left: auto; display: flex; gap: .3rem; }
.summary-preview { border-left: 4px solid #16a085; }
label.consent { display: flex; gap: .5rem; align-items: flex-start; font-size: .9em; }
label.consent input { flex: 0 0 auto; margin-top: .2rem; }
.maillog { font-size: .88em; padding-left: 0; margin-top: .8rem; }
.maillog li { list-style: none; }
▾Mweb/templates/patient.html
@@ -4,7 +4,7 @@
<nav aria-label="breadcrumb"><ul><li><a href="/patienten">Patienten</a></li><li>{{$p.Name}}</li></ul></nav>
<header class="patient-head">
<h1>{{$p.Name}}{{if $p.Archived}} <small class="muted">(archiviert)</small>{{end}}</h1>
<p class="muted">{{with $p.Dob}}geb. {{.}}{{end}}{{with $p.Phone}} · {{.}}{{end}}</p>
<p class="muted">{{with $p.Dob}}geb. {{.}}{{end}}{{with $p.Phone}} · {{.}}{{end}}{{with $p.Email}} · {{.}}{{if not $p.EmailConsent}} (keine E-Mail-Einwilligung){{end}}{{end}}</p>
</header>
<article id="befunde">
@@ -58,13 +58,59 @@
{{range .Sessions}}
<div class="session">
<div class="session-head"><strong>{{de .Date}}</strong> {{.Goal}}
<form method="post" action="/verlauf/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Löschen">✕</button></form>
<span class="session-actions">
<form method="post" action="/verlauf/{{.ID}}/zusammenfassung" class="inline"><button type="submit" class="link small-link" title="Zusammenfassung dieser Behandlung für den Patienten">📝 Für Patient</button></form>
<form method="post" action="/verlauf/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Löschen">✕</button></form>
</span>
</div>
{{with .Note}}<div class="session-note">{{.}}</div>{{end}}
</div>
{{else}}<p class="muted">Noch keine Behandlungen dokumentiert.</p>{{end}}
</article>
<article id="zusammenfassungen">
<h3>Zusammenfassungen für den Patienten</h3>
{{if .Summaries}}
<ul class="summaries">{{range .Summaries}}
<li><a href="/zusammenfassung/{{.ID}}"><strong>{{.Title}}</strong></a>
<small class="muted">{{de .Date}} · {{if eq .Kind "gesamt"}}Gesamt{{else}}Behandlung{{end}}</small>
<a href="/zusammenfassung/{{.ID}}/druck" target="_blank" class="small-link">Druck</a></li>
{{end}}</ul>
{{else}}<p class="muted">Zum Mitgeben: eine Gesamtzusammenfassung (Beschwerden, Ziele, Plan, Übungen, Fortschritt) oder nach jeder Behandlung über „📝 Für Patient“ im Behandlungsverlauf.</p>{{end}}
<form method="post" action="/patient/{{$p.ID}}/zusammenfassung" class="inline"><button type="submit" class="secondary">+ Gesamtzusammenfassung</button></form>
</article>
<article id="dokumente">
<h3>Dokumente</h3>
{{if .Docs}}
<div class="docs">
{{range .Docs}}
<figure class="doc">
<a href="/dokumente/{{.Filename}}" target="_blank" class="doc-preview">
{{if isImage .Filename}}<img src="/dokumente/{{.Filename}}" alt="{{.Title}}" loading="lazy">
{{else if isPDF .Filename}}<span class="doc-icon">PDF</span>
{{else}}<span class="doc-icon">{{.Original}}</span>{{end}}
</a>
<figcaption>
<a href="/dokumente/{{.Filename}}" target="_blank"><strong>{{.Title}}</strong></a>
<small class="muted">{{de .Date}}{{with .Category}} · {{.}}{{end}} · {{size .Size}}</small>
<form method="post" action="/dokument/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Dokument löschen">✕</button></form>
</figcaption>
</figure>
{{end}}
</div>
{{else}}<p class="muted">Noch keine Dokumente. Verordnungen, Arztbriefe, Bildgebung oder Fotos hier ablegen.</p>{{end}}
<form method="post" action="/patient/{{$p.ID}}/dokumente" enctype="multipart/form-data" class="doc-form">
<input type="file" name="files" accept="application/pdf,image/*,.heic,.heif,.tif,.tiff" multiple required aria-label="Dateien (PDF oder Foto)">
<input name="title" placeholder="Titel (optional, sonst Dateiname)" aria-label="Titel">
<input name="category" list="dl-doccat" placeholder="Kategorie" aria-label="Kategorie">
<datalist id="dl-doccat">{{range .DocCategories}}<option value="{{.}}">{{end}}</datalist>
<input type="date" name="date" value="{{.Today}}" aria-label="Datum des Dokuments">
<button type="submit">Hochladen</button>
</form>
<small class="muted">Mehrere Dateien auf einmal möglich. Auf Handy/Tablet kann direkt fotografiert werden.</small>
</article>
<div class="grid">
<article id="wissen">
<h3>Wissen zum Fall</h3>
@@ -100,6 +146,10 @@
<label>Geburtsdatum/Alter <input name="dob" value="{{$p.Dob}}"></label>
<label>Telefon <input name="phone" value="{{$p.Phone}}"></label>
</div>
<label>E-Mail <input type="email" name="email" value="{{$p.Email}}" autocomplete="off"></label>
<label class="consent"><input type="checkbox" name="email_consent" value="1"{{if $p.EmailConsent}} checked{{end}}>
Patient ist einverstanden, Zusammenfassungen mit Gesundheitsinformationen per E-Mail zu erhalten
(Einwilligung nach Art. 9 DSGVO, schriftlich dokumentieren – z. B. unter „Dokumente“).</label>
<label>Notizen <textarea name="notes" rows="3">{{$p.Notes}}</textarea></label>
<button type="submit">Speichern</button>
</form>
▾Mweb/templates/patients.html
@@ -33,6 +33,8 @@
<input name="dob" placeholder="Geburtsdatum / Alter" aria-label="Geburtsdatum">
<input name="phone" placeholder="Telefon (optional)" aria-label="Telefon">
</div>
<input type="email" name="email" placeholder="E-Mail (optional)" aria-label="E-Mail" autocomplete="off">
<label class="consent"><input type="checkbox" name="email_consent" value="1"> Einverstanden mit Zusammenfassungen per E-Mail</label>
<button type="submit">Anlegen</button>
</form>
</article>
▾Aweb/templates/summary.html
@@ -0,0 +1,55 @@
{{define "title"}}{{.S.Title}} – {{.P.Name}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ul>
<li><a href="/patienten">Patienten</a></li>
<li><a href="/patient/{{.P.ID}}#zusammenfassungen">{{.P.Name}}</a></li>
<li>{{.S.Title}}</li>
</ul></nav>
{{if .Saved}}<p class="ok" role="status">Gespeichert.</p>{{end}}
<article>
<form method="post" action="/zusammenfassung/{{.S.ID}}">
<div class="grid">
<label>Titel <input name="title" value="{{.S.Title}}" required></label>
<label>Datum <input type="date" name="date" value="{{.S.Date}}"></label>
</div>
<label>Text für den Patienten
<textarea name="body" rows="22" class="mono">{{.S.Body}}</textarea>
<small>Markdown: <code>## Überschrift</code>, <code>- Punkt</code>, <code>**fett**</code>.
Zeilen mit <code>✎</code> sind Hinweise für dich und werden nicht gedruckt, ebenso leere Abschnitte.</small>
</label>
<div class="actions">
<button type="submit">Speichern</button>
<button type="submit" name="next" value="druck" class="secondary">Speichern &amp; Druckansicht</button>
</div>
</form>
</article>
<article id="senden">
<h3>Per E-Mail senden</h3>
{{if .Sent}}<p class="ok" role="status">Gesendet an {{.P.Email}}.</p>{{end}}
{{with .MailError}}<p class="error" role="alert">{{.}}</p>{{end}}
{{if .MailBlock}}
<p class="muted">{{.MailBlock}} <a href="/patient/{{.P.ID}}">Zu den Stammdaten</a></p>
{{else}}
<form method="post" action="/zusammenfassung/{{.S.ID}}/senden" class="inline">
<button type="submit">Jetzt senden an {{.P.Email}}</button>
</form>
<small class="muted">Gesendet wird der <strong>gespeicherte</strong> Stand – vorher speichern.</small>
{{end}}
{{if .MailLog}}
<ul class="maillog">{{range .MailLog}}
<li>{{if .OK}}✓{{else}}✗{{end}} {{date .Sent}} {{.Sent.Local.Format "15:04"}} an {{.Recipient}}{{with .Error}} – <span class="error">{{.}}</span>{{end}}</li>
{{end}}</ul>
{{end}}
</article>
<article class="summary-preview">
<h3>Vorschau (gespeicherter Stand, ohne Hinweise)</h3>
<div class="prose">{{md .Preview}}</div>
</article>
<details class="danger">
<summary>Zusammenfassung löschen …</summary>
<form method="post" action="/zusammenfassung/{{.S.ID}}/loeschen"><button type="submit" class="danger">Endgültig löschen</button></form>
</details>
{{end}}
▾Aweb/templates/summary_print.html
@@ -0,0 +1,22 @@
{{define "summary_print"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.S.Title}} – {{.P.Name}}</title>
<link rel="stylesheet" href="/static/print.css">
</head>
<body class="summary">
<p class="hint">Drucken oder als PDF speichern: <kbd>Strg</kbd>+<kbd>P</kbd> (Tablet/Handy: Teilen → Drucken). ·
<a href="/zusammenfassung/{{.S.ID}}">zurück zum Bearbeiten</a></p>
<header>
<div>
<h1>{{.S.Title}}</h1>
<p class="for">für {{.P.Name}}</p>
</div>
<div class="meta"><img src="/static/logo.webp" alt="" class="logo"><br>{{.Practice}}<br>{{de .S.Date}}</div>
</header>
<main class="summary-body">{{md .Body}}</main>
<footer>Bei Fragen sprechen Sie mich gern beim nächsten Termin an.</footer>
</body>
</html>{{end}}