[OCaml] High Intensity Training Online
1
open Lwt.Infix
2
3
exception Corrupt of Codec.error
4
5
type t = {
6
pool : (Caqti_lwt.connection, Caqti_error.t) Caqti_lwt_unix.Pool.t;
7
mutable next_trainee : int;
8
}
9
10
(* --- request definitions --- *)
11
12
module Q = struct
13
open Caqti_request.Infix
14
open Caqti_type.Std
15
16
let insert_trainee =
17
(t3 string string string ->. unit)
18
"INSERT INTO trainee (id, username, credential) VALUES (?, ?, ?)"
19
20
let trainee_by_username =
21
(string ->? t3 string string string)
22
"SELECT id, username, credential FROM trainee WHERE username = ?"
23
24
let trainee_by_id =
25
(string ->? t3 string string string)
26
"SELECT id, username, credential FROM trainee WHERE id = ?"
27
28
let next_trainee =
29
(unit ->! int)
30
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 2) AS INTEGER)), 0) + 1 FROM \
31
trainee WHERE id GLOB 't[0-9]*'"
32
33
let update_username =
34
(t2 string string ->. unit) "UPDATE trainee SET username = ? WHERE id = ?"
35
36
let update_credential =
37
(t2 string string ->. unit) "UPDATE trainee SET credential = ? WHERE id = ?"
38
39
let get_active =
40
(string ->? string)
41
"SELECT routine_id FROM active_routine WHERE trainee_id = ?"
42
43
let set_active =
44
(t2 string string ->. unit)
45
"INSERT INTO active_routine (trainee_id, routine_id) VALUES (?, ?) ON \
46
CONFLICT (trainee_id) DO UPDATE SET routine_id = excluded.routine_id"
47
48
let get_in_progress =
49
(string ->? string) "SELECT encoded FROM in_progress WHERE trainee_id = ?"
50
51
let set_in_progress =
52
(t2 string string ->. unit)
53
"INSERT INTO in_progress (trainee_id, encoded) VALUES (?, ?) ON CONFLICT \
54
(trainee_id) DO UPDATE SET encoded = excluded.encoded"
55
56
let clear_in_progress =
57
(string ->. unit) "DELETE FROM in_progress WHERE trainee_id = ?"
58
59
let next_seq =
60
(string ->! int)
61
"SELECT COALESCE(MAX(seq), 0) + 1 FROM workout WHERE trainee_id = ?"
62
63
let insert_workout =
64
(t4 string string int string ->. unit)
65
"INSERT INTO workout (id, trainee_id, seq, encoded) VALUES (?, ?, ?, ?)"
66
67
let workout_by_id =
68
(t2 string string ->? string)
69
"SELECT encoded FROM workout WHERE trainee_id = ? AND id = ?"
70
71
let update_workout =
72
(t3 string string string ->. unit)
73
"UPDATE workout SET encoded = ? WHERE trainee_id = ? AND id = ?"
74
75
let history =
76
(string ->* t2 string string)
77
"SELECT id, encoded FROM workout WHERE trainee_id = ? ORDER BY seq DESC"
78
79
let next_feedback_seq =
80
(string ->! int)
81
"SELECT COALESCE(MAX(seq), 0) + 1 FROM feedback WHERE trainee_id = ?"
82
83
let insert_feedback =
84
(t3 string int string ->. unit)
85
"INSERT INTO feedback (trainee_id, seq, encoded) VALUES (?, ?, ?)"
86
87
let feedback =
88
(string ->* string)
89
"SELECT encoded FROM feedback WHERE trainee_id = ? ORDER BY seq DESC"
90
91
let next_app_feedback_seq =
92
(string ->! int)
93
"SELECT COALESCE(MAX(seq), 0) + 1 FROM app_feedback WHERE trainee_id = ?"
94
95
let insert_app_feedback =
96
(t4 string int string string ->. unit)
97
"INSERT INTO app_feedback (trainee_id, seq, submitted_at, message) \
98
VALUES (?, ?, ?, ?)"
99
100
let app_feedback =
101
(unit ->* t7 string int string string string int int)
102
"SELECT f.trainee_id, f.seq, t.username, f.submitted_at, f.message, \
103
(SELECT COUNT(*) FROM app_feedback c WHERE c.trainee_id = \
104
f.trainee_id), (SELECT COUNT(*) FROM app_feedback_vote v WHERE \
105
v.feedback_trainee_id = f.trainee_id AND v.feedback_seq = f.seq) FROM \
106
app_feedback f JOIN trainee t ON t.id = f.trainee_id ORDER BY 7 DESC, \
107
f.submitted_at DESC, f.trainee_id, f.seq DESC"
108
109
let viewer_votes =
110
(string ->* t2 string int)
111
"SELECT feedback_trainee_id, feedback_seq FROM app_feedback_vote WHERE \
112
voter_id = ?"
113
114
let feedback_owner =
115
(t2 string int ->? string)
116
"SELECT trainee_id FROM app_feedback WHERE trainee_id = ? AND seq = ?"
117
118
let vote_exists =
119
(t3 string int string ->? int)
120
"SELECT 1 FROM app_feedback_vote WHERE feedback_trainee_id = ? AND \
121
feedback_seq = ? AND voter_id = ?"
122
123
let insert_vote =
124
(t3 string int string ->. unit)
125
"INSERT INTO app_feedback_vote (feedback_trainee_id, feedback_seq, \
126
voter_id) VALUES (?, ?, ?)"
127
128
let update_app_feedback =
129
(t3 string string int ->. unit)
130
"UPDATE app_feedback SET message = ? WHERE trainee_id = ? AND seq = ?"
131
132
let delete_feedback_votes =
133
(t2 string int ->. unit)
134
"DELETE FROM app_feedback_vote WHERE feedback_trainee_id = ? AND \
135
feedback_seq = ?"
136
137
let delete_app_feedback =
138
(t2 string int ->. unit)
139
"DELETE FROM app_feedback WHERE trainee_id = ? AND seq = ?"
140
end
141
142
(* --- pool helper --- *)
143
144
let run t f = Caqti_lwt_unix.Pool.use f t.pool >>= Caqti_lwt.or_fail
145
146
let connect uri =
147
match Caqti_lwt_unix.connect_pool (Uri.of_string uri) with
148
| Error e -> Lwt.return (Error (e :> Caqti_error.t))
149
| Ok pool ->
150
let open Lwt_result.Syntax in
151
let* () = Caqti_lwt_unix.Pool.use Migrations.apply pool in
152
let* next_trainee =
153
Caqti_lwt_unix.Pool.use
154
(fun (module Db : Caqti_lwt.CONNECTION) -> Db.find Q.next_trainee ())
155
pool
156
in
157
Lwt_result.return { pool; next_trainee }
158
159
(* --- decoding stored workouts --- *)
160
161
let find_routine_by_name = Catalog.find_by_name
162
163
let decode encoded =
164
match Codec.decode_workout ~find_routine:find_routine_by_name encoded with
165
| Ok workout -> workout
166
| Error e -> raise (Corrupt e)
167
168
(* --- accounts --- *)
169
170
let create_trainee t ~username ~credential =
171
let username_s = Trainee.username_to_string username in
172
let credential_s = Trainee.credential_to_hash credential in
173
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
174
Db.find_opt Q.trainee_by_username username_s)
175
>>= function
176
| Some _ -> Lwt.return (Error `Username_taken)
177
| None ->
178
let id = Printf.sprintf "t%d" t.next_trainee in
179
t.next_trainee <- t.next_trainee + 1;
180
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
181
Db.exec Q.insert_trainee (id, username_s, credential_s))
182
>|= fun () -> Ok { Trainee.id = Trainee.id id; username; credential }
183
184
let trainee_of_row (id, username, credential) =
185
match Trainee.username username with
186
| Ok username ->
187
Some
188
{
189
Trainee.id = Trainee.id id;
190
username;
191
credential = Trainee.credential_of_hash credential;
192
}
193
| Error _ -> None
194
195
let find_trainee_by_username t username =
196
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
197
Db.find_opt Q.trainee_by_username (Trainee.username_to_string username))
198
>|= function
199
| Some row -> trainee_of_row row
200
| None -> None
201
202
let find_trainee t id =
203
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
204
Db.find_opt Q.trainee_by_id (Trainee.id_to_string id))
205
>|= function
206
| Some row -> trainee_of_row row
207
| None -> None
208
209
(* Rename in one transaction: check the name is free for anyone else, then
210
update. Running both on the same connection keeps the check and the write
211
from racing another rename. The trainee's own current name is a no-op. *)
212
let update_username t id (username : Trainee.username) =
213
let trainee = Trainee.id_to_string id in
214
let username_s = Trainee.username_to_string username in
215
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
216
let open Lwt_result.Syntax in
217
Db.with_transaction (fun () ->
218
let* holder = Db.find_opt Q.trainee_by_username username_s in
219
match holder with
220
| Some (holder_id, _, _) when not (String.equal holder_id trainee) ->
221
Lwt_result.return (Error `Username_taken)
222
| _ ->
223
let* () = Db.exec Q.update_username (username_s, trainee) in
224
let* row = Db.find_opt Q.trainee_by_id trainee in
225
Lwt_result.return
226
(match Option.bind row trainee_of_row with
227
| Some updated -> Ok updated
228
| None -> Error `Username_taken)))
229
230
let update_credential t id credential =
231
let trainee = Trainee.id_to_string id in
232
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
233
Db.exec Q.update_credential
234
(Trainee.credential_to_hash credential, trainee))
235
>>= fun () -> find_trainee t id
236
237
(* --- catalog --- *)
238
239
let list_routines _ = Catalog.routines
240
let find_routine _ id = Catalog.find id
241
242
(* The routine name a workout's prescription belongs to, for encoding. Only the
243
ideal routine exists, but resolve it honestly rather than hard-code. *)
244
let routine_name_of workout =
245
let workout_name =
246
Prescription.Workout.name (Evidence.Workout.prescription workout)
247
in
248
List.find_map
249
(fun (_, routine) ->
250
if
251
List.exists
252
(fun w -> String.equal (Prescription.Workout.name w) workout_name)
253
(Prescription.Routine.workouts routine)
254
then Some (Prescription.Routine.name routine)
255
else None)
256
Catalog.routines
257
|> Option.value ~default:""
258
259
let encode workout =
260
Codec.encode_workout ~routine_name:(routine_name_of workout) workout
261
262
(* --- per-trainee selection and workout in progress --- *)
263
264
let active_routine t id =
265
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
266
Db.find_opt Q.get_active (Trainee.id_to_string id))
267
>|= Option.map Repository.routine_id
268
269
let set_active_routine t id (routine : Repository.routine_id) =
270
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
271
Db.exec Q.set_active (Trainee.id_to_string id, (routine :> string)))
272
273
let in_progress t id =
274
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
275
Db.find_opt Q.get_in_progress (Trainee.id_to_string id))
276
>|= function
277
| None -> None
278
| Some encoded -> Some (decode encoded)
279
280
let set_in_progress t id workout =
281
let trainee = Trainee.id_to_string id in
282
match workout with
283
| None ->
284
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
285
Db.exec Q.clear_in_progress trainee)
286
| Some workout ->
287
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
288
Db.exec Q.set_in_progress (trainee, encode workout))
289
290
(* --- history --- *)
291
292
(* Save a finished workout and clear the in-progress slot in one transaction,
293
so the two rows never disagree after a crash. Both statements run on the same
294
connection, checked out once, inside [with_transaction]. *)
295
let finish_workout t id workout =
296
let trainee = Trainee.id_to_string id in
297
let encoded = encode workout in
298
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
299
let open Lwt_result.Syntax in
300
Db.with_transaction (fun () ->
301
let* seq = Db.find Q.next_seq trainee in
302
let wid = Printf.sprintf "%s:w%d" trainee seq in
303
let* () = Db.exec Q.insert_workout (wid, trainee, seq, encoded) in
304
let* () = Db.exec Q.clear_in_progress trainee in
305
Lwt_result.return
306
{ Repository.id = Repository.workout_id wid; workout }))
307
308
let find t id (wid : Repository.workout_id) =
309
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
310
Db.find_opt Q.workout_by_id (Trainee.id_to_string id, (wid :> string)))
311
>|= function
312
| None -> None
313
| Some encoded -> Some { Repository.id = wid; workout = decode encoded }
314
315
let replace t id record =
316
let trainee = Trainee.id_to_string id in
317
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
318
Db.find_opt Q.workout_by_id (trainee, (record.Repository.id :> string)))
319
>>= function
320
| None -> Lwt.return false
321
| Some _ ->
322
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
323
Db.exec Q.update_workout
324
( encode record.Repository.workout,
325
trainee,
326
(record.Repository.id :> string) ))
327
>|= fun () -> true
328
329
let history t id =
330
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
331
Db.collect_list Q.history (Trainee.id_to_string id))
332
>|= fun rows ->
333
List.map
334
(fun (wid, encoded) ->
335
{ Repository.id = Repository.workout_id wid; workout = decode encoded })
336
rows
337
338
let log t id =
339
history t id >|= fun records ->
340
List.fold_left
341
(fun log record -> Evidence.Log.add log record.Repository.workout)
342
Evidence.Log.empty (List.rev records)
343
344
(* --- subjective feedback --- *)
345
346
let save_feedback t id report =
347
let trainee = Trainee.id_to_string id in
348
let encoded = Codec.encode_feedback report in
349
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
350
Db.find Q.next_feedback_seq trainee)
351
>>= fun seq ->
352
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
353
Db.exec Q.insert_feedback (trainee, seq, encoded))
354
355
let decode_feedback encoded =
356
match Codec.decode_feedback encoded with
357
| Ok report -> report
358
| Error e -> raise (Corrupt e)
359
360
let feedback t id =
361
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
362
Db.collect_list Q.feedback (Trainee.id_to_string id))
363
>|= List.map decode_feedback
364
365
let save_app_feedback t id ~submitted_at ~message =
366
let trainee = Trainee.id_to_string id in
367
find_trainee t id >>= function
368
| None ->
369
Lwt.fail_with "cannot save application feedback for an unknown trainee"
370
| Some author ->
371
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
372
Db.find Q.next_app_feedback_seq trainee)
373
>>= fun seq ->
374
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
375
Db.exec Q.insert_app_feedback
376
(trainee, seq, Timestamp.encode submitted_at, message))
377
>|= fun () ->
378
Repository.
379
{
380
feedback_id = app_feedback_id (Printf.sprintf "%s:%d" trainee seq);
381
author = Trainee.username_to_string author.Trainee.username;
382
contributions = seq;
383
submitted_at;
384
message;
385
upvotes = 0;
386
viewer_upvoted = false;
387
viewer_owns = true;
388
}
389
390
let app_feedback t ~viewer =
391
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
392
Db.collect_list Q.app_feedback ())
393
>>= fun rows ->
394
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
395
Db.collect_list Q.viewer_votes (Trainee.id_to_string viewer))
396
>|= fun voted ->
397
List.map
398
(fun (trainee, seq, author, submitted_at, message, contributions, upvotes)
399
->
400
let submitted_at =
401
match Timestamp.decode submitted_at with
402
| Ok timestamp -> timestamp
403
| Error _ ->
404
raise (Corrupt (Codec.Malformed "application feedback timestamp"))
405
in
406
Repository.
407
{
408
feedback_id = app_feedback_id (Printf.sprintf "%s:%d" trainee seq);
409
author;
410
contributions;
411
submitted_at;
412
message;
413
upvotes;
414
viewer_upvoted = List.mem (trainee, seq) voted;
415
viewer_owns = String.equal trainee (Trainee.id_to_string viewer);
416
})
417
rows
418
419
let split_app_feedback_id id =
420
let raw = Repository.app_feedback_id_to_string id in
421
match String.rindex_opt raw ':' with
422
| None -> None
423
| Some separator ->
424
let trainee = String.sub raw 0 separator in
425
let seq_start = separator + 1 in
426
let seq_length = String.length raw - seq_start in
427
Option.map
428
(fun seq -> (trainee, seq))
429
(int_of_string_opt (String.sub raw seq_start seq_length))
430
431
let upvote_app_feedback t ~voter id =
432
match split_app_feedback_id id with
433
| None -> Lwt.return false
434
| Some (trainee, seq) -> (
435
let voter = Trainee.id_to_string voter in
436
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
437
Db.find_opt Q.feedback_owner (trainee, seq))
438
>>= function
439
| None -> Lwt.return false
440
| Some owner when String.equal owner voter -> Lwt.return false
441
| Some _ -> (
442
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
443
Db.find_opt Q.vote_exists (trainee, seq, voter))
444
>>= function
445
| Some _ -> Lwt.return false
446
| None ->
447
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
448
Db.exec Q.insert_vote (trainee, seq, voter))
449
>|= fun () -> true))
450
451
let update_app_feedback t ~author id ~message =
452
match split_app_feedback_id id with
453
| None -> Lwt.return false
454
| Some (trainee, seq) -> (
455
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
456
Db.find_opt Q.feedback_owner (trainee, seq))
457
>>= function
458
| Some owner when String.equal owner (Trainee.id_to_string author) ->
459
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
460
Db.exec Q.update_app_feedback (message, trainee, seq))
461
>|= fun () -> true
462
| _ -> Lwt.return false)
463
464
let delete_app_feedback t ~author id =
465
match split_app_feedback_id id with
466
| None -> Lwt.return false
467
| Some (trainee, seq) -> (
468
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
469
Db.find_opt Q.feedback_owner (trainee, seq))
470
>>= function
471
| Some owner when String.equal owner (Trainee.id_to_string author) ->
472
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
473
Db.exec Q.delete_feedback_votes (trainee, seq))
474
>>= fun () ->
475
run t (fun (module Db : Caqti_lwt.CONNECTION) ->
476
Db.exec Q.delete_app_feedback (trainee, seq))
477
>|= fun () -> true
478
| _ -> Lwt.return false)
479