appointments.go
⎇
Raw
1package store
2
3import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "time"
10)
11
12type Therapist struct {
13 ID int64
14 Name string
15 Active bool
16}
17
18type 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
30func (a Appointment) End() time.Time { return a.Start.Add(time.Duration(a.Duration) * time.Minute) }
31
32func 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
42func (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
60func (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
65func (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
72const 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
76func 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
85func (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.
103func (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.
112func (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).
118func (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
126func (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
137func nullID(id int64) any {
138 if id > 0 {
139 return id
140 }
141 return nil
142}
143
144func (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.
154func (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
167func (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.
176func (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.
188func (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.
201func (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