appointments.go
| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "database/sql" |
| 7 | "encoding/hex" |
| 8 | "errors" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | type Therapist struct { |
| 13 | ID int64 |
| 14 | Name string |
| 15 | Active bool |
| 16 | } |
| 17 | |
| 18 | type Appointment struct { |
| 19 | ID, PatientID, TherapistID int64 |
| 20 | UID string |
| 21 | Start time.Time |
| 22 | Duration int // Minuten |
| 23 | Title, Location, Note string |
| 24 | Cancelled bool |
| 25 | Sequence int |
| 26 | Updated time.Time |
| 27 | TherapistName, PatientName string |
| 28 | } |
| 29 | |
| 30 | func (a Appointment) End() time.Time { return a.Start.Add(time.Duration(a.Duration) * time.Minute) } |
| 31 | |
| 32 | func randomToken(n int) string { |
| 33 | b := make([]byte, n) |
| 34 | if _, err := rand.Read(b); err != nil { |
| 35 | panic(err) // crypto/rand schlägt praktisch nie fehl |
| 36 | } |
| 37 | return hex.EncodeToString(b) |
| 38 | } |
| 39 | |
| 40 | // ---------- Behandler ---------- |
| 41 | |
| 42 | func (s *Store) Therapists(ctx context.Context, activeOnly bool) ([]Therapist, error) { |
| 43 | rows, err := s.db.QueryContext(ctx, `SELECT id, name, active FROM therapists WHERE (? = 0 OR active = 1) |
| 44 | ORDER BY active DESC, name COLLATE NOCASE`, activeOnly) |
| 45 | if err != nil { |
| 46 | return nil, err |
| 47 | } |
| 48 | defer rows.Close() |
| 49 | var out []Therapist |
| 50 | for rows.Next() { |
| 51 | var t Therapist |
| 52 | if err := rows.Scan(&t.ID, &t.Name, &t.Active); err != nil { |
| 53 | return nil, err |
| 54 | } |
| 55 | out = append(out, t) |
| 56 | } |
| 57 | return out, rows.Err() |
| 58 | } |
| 59 | |
| 60 | func (s *Store) CreateTherapist(ctx context.Context, name string) error { |
| 61 | _, err := s.db.ExecContext(ctx, `INSERT INTO therapists (name) VALUES (?)`, name) |
| 62 | return err |
| 63 | } |
| 64 | |
| 65 | func (s *Store) UpdateTherapist(ctx context.Context, t Therapist) error { |
| 66 | _, err := s.db.ExecContext(ctx, `UPDATE therapists SET name = ?, active = ? WHERE id = ?`, t.Name, t.Active, t.ID) |
| 67 | return err |
| 68 | } |
| 69 | |
| 70 | // ---------- Termine ---------- |
| 71 | |
| 72 | const apptSelect = `SELECT a.id, a.uid, a.patient_id, COALESCE(a.therapist_id, 0), a.starts_at, a.duration_min, a.title, |
| 73 | a.location, a.note, a.cancelled, a.sequence, a.updated_at, COALESCE(t.name, ''), p.name |
| 74 | FROM appointments a JOIN patients p ON p.id = a.patient_id LEFT JOIN therapists t ON t.id = a.therapist_id` |
| 75 | |
| 76 | func scanAppt(sc scanner) (Appointment, error) { |
| 77 | var a Appointment |
| 78 | var start, upd int64 |
| 79 | err := sc.Scan(&a.ID, &a.UID, &a.PatientID, &a.TherapistID, &start, &a.Duration, &a.Title, &a.Location, &a.Note, |
| 80 | &a.Cancelled, &a.Sequence, &upd, &a.TherapistName, &a.PatientName) |
| 81 | a.Start, a.Updated = time.Unix(start, 0), time.Unix(upd, 0) |
| 82 | return a, err |
| 83 | } |
| 84 | |
| 85 | func (s *Store) appts(ctx context.Context, where string, args ...any) ([]Appointment, error) { |
| 86 | rows, err := s.db.QueryContext(ctx, apptSelect+" "+where, args...) |
| 87 | if err != nil { |
| 88 | return nil, err |
| 89 | } |
| 90 | defer rows.Close() |
| 91 | var out []Appointment |
| 92 | for rows.Next() { |
| 93 | a, err := scanAppt(rows) |
| 94 | if err != nil { |
| 95 | return nil, err |
| 96 | } |
| 97 | out = append(out, a) |
| 98 | } |
| 99 | return out, rows.Err() |
| 100 | } |
| 101 | |
| 102 | // PatientAppointments ab einem Zeitpunkt (Null-Zeit = alle), aufsteigend. |
| 103 | func (s *Store) PatientAppointments(ctx context.Context, pid int64, from time.Time) ([]Appointment, error) { |
| 104 | var since int64 |
| 105 | if !from.IsZero() { |
| 106 | since = from.Unix() |
| 107 | } |
| 108 | return s.appts(ctx, `WHERE a.patient_id = ? AND a.starts_at >= ? ORDER BY a.starts_at`, pid, since) |
| 109 | } |
| 110 | |
| 111 | // AppointmentsBetween für die Praxisübersicht, optional nach Behandler gefiltert. |
| 112 | func (s *Store) AppointmentsBetween(ctx context.Context, from, to time.Time, therapistID int64) ([]Appointment, error) { |
| 113 | return s.appts(ctx, `WHERE a.starts_at >= ? AND a.starts_at < ? AND (? = 0 OR a.therapist_id = ?) ORDER BY a.starts_at`, |
| 114 | from.Unix(), to.Unix(), therapistID, therapistID) |
| 115 | } |
| 116 | |
| 117 | // NextAppointment nach einem Zeitpunkt (nicht abgesagt). |
| 118 | func (s *Store) NextAppointment(ctx context.Context, pid int64, after time.Time) (Appointment, bool, error) { |
| 119 | as, err := s.appts(ctx, `WHERE a.patient_id = ? AND a.starts_at > ? AND a.cancelled = 0 ORDER BY a.starts_at LIMIT 1`, pid, after.Unix()) |
| 120 | if err != nil || len(as) == 0 { |
| 121 | return Appointment{}, false, err |
| 122 | } |
| 123 | return as[0], true, nil |
| 124 | } |
| 125 | |
| 126 | func (s *Store) Appointment(ctx context.Context, id int64) (Appointment, error) { |
| 127 | as, err := s.appts(ctx, `WHERE a.id = ?`, id) |
| 128 | if err != nil { |
| 129 | return Appointment{}, err |
| 130 | } |
| 131 | if len(as) == 0 { |
| 132 | return Appointment{}, ErrNotFound |
| 133 | } |
| 134 | return as[0], nil |
| 135 | } |
| 136 | |
| 137 | func nullID(id int64) any { |
| 138 | if id > 0 { |
| 139 | return id |
| 140 | } |
| 141 | return nil |
| 142 | } |
| 143 | |
| 144 | func (s *Store) CreateAppointment(ctx context.Context, a Appointment) (int64, error) { |
| 145 | res, err := s.db.ExecContext(ctx, `INSERT INTO appointments (uid, patient_id, therapist_id, starts_at, duration_min, title, location, note) |
| 146 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, randomToken(16), a.PatientID, nullID(a.TherapistID), a.Start.Unix(), a.Duration, a.Title, a.Location, a.Note) |
| 147 | if err != nil { |
| 148 | return 0, err |
| 149 | } |
| 150 | return res.LastInsertId() |
| 151 | } |
| 152 | |
| 153 | // UpdateAppointment erhöht sequence, damit Kalender-Apps die Änderung übernehmen. |
| 154 | func (s *Store) UpdateAppointment(ctx context.Context, a Appointment) error { |
| 155 | res, err := s.db.ExecContext(ctx, `UPDATE appointments SET therapist_id=?, starts_at=?, duration_min=?, title=?, location=?, note=?, |
| 156 | cancelled=?, sequence=sequence+1, updated_at=unixepoch() WHERE id=?`, |
| 157 | nullID(a.TherapistID), a.Start.Unix(), a.Duration, a.Title, a.Location, a.Note, a.Cancelled, a.ID) |
| 158 | if err != nil { |
| 159 | return err |
| 160 | } |
| 161 | if n, _ := res.RowsAffected(); n == 0 { |
| 162 | return ErrNotFound |
| 163 | } |
| 164 | return nil |
| 165 | } |
| 166 | |
| 167 | func (s *Store) DeleteAppointment(ctx context.Context, id int64) (int64, error) { |
| 168 | var pid int64 |
| 169 | err := s.db.QueryRowContext(ctx, `DELETE FROM appointments WHERE id = ? RETURNING patient_id`, id).Scan(&pid) |
| 170 | return pid, notFound(err) |
| 171 | } |
| 172 | |
| 173 | // ---------- Kalender-Link ---------- |
| 174 | |
| 175 | // CalToken liefert den Abo-Token des Patienten und legt ihn bei Bedarf an. |
| 176 | func (s *Store) CalToken(ctx context.Context, pid int64) (string, error) { |
| 177 | var tok sql.NullString |
| 178 | if err := s.db.QueryRowContext(ctx, `SELECT cal_token FROM patients WHERE id = ?`, pid).Scan(&tok); err != nil { |
| 179 | return "", notFound(err) |
| 180 | } |
| 181 | if tok.Valid && tok.String != "" { |
| 182 | return tok.String, nil |
| 183 | } |
| 184 | return s.RotateCalToken(ctx, pid) |
| 185 | } |
| 186 | |
| 187 | // RotateCalToken erzeugt einen neuen Link; der alte funktioniert danach nicht mehr. |
| 188 | func (s *Store) RotateCalToken(ctx context.Context, pid int64) (string, error) { |
| 189 | tok := randomToken(24) |
| 190 | res, err := s.db.ExecContext(ctx, `UPDATE patients SET cal_token = ? WHERE id = ?`, tok, pid) |
| 191 | if err != nil { |
| 192 | return "", err |
| 193 | } |
| 194 | if n, _ := res.RowsAffected(); n == 0 { |
| 195 | return "", ErrNotFound |
| 196 | } |
| 197 | return tok, nil |
| 198 | } |
| 199 | |
| 200 | // PatientByCalToken für den öffentlichen Feed. |
| 201 | func (s *Store) PatientByCalToken(ctx context.Context, token string) (int64, error) { |
| 202 | if len(token) < 32 { |
| 203 | return 0, ErrNotFound |
| 204 | } |
| 205 | var pid int64 |
| 206 | err := s.db.QueryRowContext(ctx, `SELECT id FROM patients WHERE cal_token = ?`, token).Scan(&pid) |
| 207 | if errors.Is(err, sql.ErrNoRows) { |
| 208 | return 0, ErrNotFound |
| 209 | } |
| 210 | return pid, err |
| 211 | } |
| 212 |