View raw

1 open Lwt.Infix 2 open Hito_app 3 4 module Make (R : Repository.S) (H : Password_hash.S) = struct 5 module Service = Service.Make (R) (H) 6 7 type t = { 8 service : Service.t; 9 now : unit -> Recovery.timestamp; 10 registration_open : bool; 11 } 12 13 let default_now () = 14 Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ())) 15 16 let make ~repo ?(now = default_now) ?(registration_open = false) () = 17 { service = Service.make ~repo; now; registration_open } 18 19 let flash_key = "hito.flash" 20 let html ?status page = Dream_html.respond ?status page 21 let redirect request path = Dream_html.redirect request path 22 23 let redirect_to request path = 24 redirect request (Dream_html.path_attr Dream_html.HTML.href path) 25 26 let redirect_with_flash request path message = 27 Dream.set_session_field request flash_key message >>= fun () -> 28 redirect_to request path 29 30 let redirect_with_flash_attr request path message = 31 Dream.set_session_field request flash_key message >>= fun () -> 32 redirect request path 33 34 let redirect_with_flash_raw request path message = 35 Dream.set_session_field request flash_key message >>= fun () -> 36 Dream.redirect request path 37 38 let not_found detail = 39 html (Pages.problem ~title:"Not found" ~detail) ~status:`Not_Found 40 41 let bad_request detail = 42 html (Pages.problem ~title:"Invalid request" ~detail) ~status:`Bad_Request 43 44 (* --- presentation of errors --- 45 46 Every domain and service error becomes a user-facing sentence here, so a 47 handler renders a message rather than deciding its wording. One module owns 48 the phrasing, and adding an error variant surfaces as a missing case. *) 49 module Present = struct 50 let form_invalid = "The submitted form is not valid." 51 52 let registration : Service.register_error -> string = function 53 | `Username e -> Format.asprintf "%a" Trainee.pp_username_error e 54 | `Username_taken -> "That username is already registered." 55 56 let sign_in_failed = "That username and password do not match." 57 let routine = Format.asprintf "%a" Service.pp_error 58 59 let log_error : Service.log_error -> string = function 60 | Service.No_workout_in_progress -> "No workout is in progress." 61 | Service.Rejected error -> 62 Format.asprintf "%a" Evidence.Workout.pp_error error 63 64 let edit_error : Service.edit_error -> string = function 65 | Service.Unknown_workout -> "That saved workout no longer exists." 66 | Service.Rejected_edit error -> 67 Format.asprintf "%a" Evidence.Workout.pp_error error 68 69 let unknown_routine = "That routine is not in the catalogue." 70 let unknown_record = "That saved workout no longer exists." 71 let no_workout = "No workout is in progress." 72 let slot_not_awaiting = "That slot is not awaiting a record." 73 74 let change_username : Service.change_username_error -> string = function 75 | `Username e -> Format.asprintf "%a" Trainee.pp_username_error e 76 | `Username_taken -> "That username is already registered." 77 | `Unknown -> "That account no longer exists." 78 79 let change_password : Service.change_password_error -> string = function 80 | `Incorrect_password -> "The current password is not correct." 81 | `Unknown -> "That account no longer exists." 82 83 let app_feedback_empty = "Enter feedback before submitting." 84 let username_changed = "Username changed." 85 let password_changed = "Password changed." 86 end 87 88 (* --- sessions and authentication --- *) 89 90 let session_key = "trainee" 91 92 let current_trainee t request = 93 match Dream.session_field request session_key with 94 | None -> Lwt.return None 95 | Some id -> Service.find_trainee t.service (Trainee.id id) 96 97 (* Resolve the authenticated trainee, or send an unauthenticated request to 98 the sign-in page. [handler] receives the trainee. The request and any path 99 captures stay in the caller's scope, so this one combinator serves every 100 route regardless of how many captures Dream passes. *) 101 let authenticated t request handler = 102 current_trainee t request >>= function 103 | Some trainee -> ( 104 handler trainee >>= fun response -> 105 match Dream.method_ request with 106 | `GET -> 107 Dream.drop_session_field request flash_key >|= fun () -> response 108 | _ -> Lwt.return response) 109 | None -> redirect_to request Routes.login 110 111 let decode_form decoder request = 112 Dream_html.form decoder ~csrf:true request >|= function 113 | `Ok value -> Ok value 114 | `Invalid errors -> Error (`Invalid errors) 115 | _ -> Error `Bad_request 116 117 (* Plain (non-dream-html) form read with CSRF, for auth forms. *) 118 let read_credentials request = 119 Dream.form request >|= function 120 | `Ok fields -> ( 121 let get k = List.assoc_opt k fields in 122 match (get "username", get "password") with 123 | Some username, Some password -> Ok (username, password) 124 | _ -> Error `Bad_request) 125 | _ -> Error `Bad_request 126 127 (* A CSRF guard for state-changing POSTs whose body carries no fields other 128 than the token. [Dream.form] verifies the token and returns [`Ok] only 129 when it is valid, so this rejects a forged or missing token. *) 130 let guard_csrf request = 131 Dream.form request >|= function `Ok _ -> Ok () | _ -> Error `Bad_request 132 133 module Auth = struct 134 let login_page t request = 135 current_trainee t request >>= function 136 | Some _ -> redirect_to request Routes.home 137 | None -> 138 html (Pages.login request ~registration_open:t.registration_open ()) 139 140 let register_page t request = 141 current_trainee t request >>= function 142 | Some _ -> redirect_to request Routes.home 143 | None -> html (Pages.register request ()) 144 145 let establish request (trainee : Trainee.t) = 146 Dream.set_session_field request session_key 147 (Trainee.id_to_string trainee.id) 148 >>= fun () -> redirect_to request Routes.home 149 150 let register t request = 151 read_credentials request >>= function 152 | Error _ -> 153 html ~status:`Bad_Request 154 (Pages.register request ~error:Present.form_invalid ()) 155 | Ok (username, password) -> ( 156 Service.register t.service ~username ~password >>= function 157 | Ok trainee -> establish request trainee 158 | Error err -> 159 html ~status:`Bad_Request 160 (Pages.register request ~error:(Present.registration err) ())) 161 162 let login t request = 163 read_credentials request >>= function 164 | Error _ -> 165 html ~status:`Bad_Request 166 (Pages.login request ~error:Present.form_invalid ()) 167 | Ok (username, password) -> ( 168 Service.authenticate t.service ~username ~password >>= function 169 | Some trainee -> establish request trainee 170 | None -> 171 html ~status:`Unauthorized 172 (Pages.login request ~error:Present.sign_in_failed ())) 173 174 let logout _t request = 175 guard_csrf request >>= function 176 | Error _ -> bad_request Present.form_invalid 177 | Ok () -> 178 Dream.invalidate_session request >>= fun () -> 179 redirect_to request Routes.login 180 181 let routes t = 182 (* Public sign-up is disabled by default. The [/register] routes appear 183 only when [registration_open] is set. Everything else is unconditional. *) 184 let register_routes = 185 if t.registration_open then 186 [ 187 Dream_html.get Routes.register (register_page t); 188 Dream_html.post Routes.register (register t); 189 ] 190 else [] 191 in 192 [ 193 Dream_html.get Routes.login (login_page t); 194 Dream_html.post Routes.login (login t); 195 Dream_html.post Routes.logout (logout t); 196 ] 197 @ register_routes 198 end 199 200 (* --- helpers over the service --- *) 201 202 let record_target t trainee id = 203 Service.find_record t.service trainee (Repository.workout_id id) 204 >|= Option.map (fun record -> 205 (record.Repository.id, record.Repository.workout)) 206 207 let outstanding workout slot = 208 List.assoc_opt slot (Evidence.Workout.outstanding workout) 209 210 (* The prescription at any valid slot, filled or not — the edit handlers 211 correct filled slots, which [outstanding] does not list. *) 212 let prescription_at workout slot = 213 List.nth_opt 214 (Prescription.Workout.stimuli (Evidence.Workout.prescription workout)) 215 slot 216 217 (* The slot a workout view should open on: the first slot still awaiting a 218 record, or the first slot when every slot is filled. A caller clamps a 219 requested slot against the prescription. This supplies the default. *) 220 let default_slot workout = 221 match Evidence.Workout.outstanding workout with 222 | (slot, _) :: _ -> slot 223 | [] -> 0 224 225 (* The slot a workout view should open on. An explicit [?slot=] query 226 wins when it names a real slot. Otherwise the view defaults to the first 227 incomplete slot. A [?slot=] out of range, or absent, falls back to the 228 default, so a bookmarked or hand-edited URL never renders an empty view. *) 229 let active_slot request workout = 230 let slot_count = 231 List.length 232 (Prescription.Workout.stimuli (Evidence.Workout.prescription workout)) 233 in 234 match Dream.query request "slot" with 235 | Some raw -> ( 236 match int_of_string_opt raw with 237 | Some slot when slot >= 0 && slot < slot_count -> slot 238 | _ -> default_slot workout) 239 | None -> default_slot workout 240 241 (* Map an [Evidence.Workout.t] into the view-model DTO the presenter renders, 242 so no Evidence or Prescription type crosses the boundary into Pages. The 243 same mapping serves the live workout and a saved-record view. The caller 244 supplies [record_id]/[editing]/[errors] separately. *) 245 246 (* The ending code a single-choice form uses, taken from an effort's outcome. 247 The form offers one ending, so the first extension stands for it. *) 248 let extension_code_of_effort effort = 249 match Evidence.Stimulus.Effort.outcome effort with 250 | Evidence.Stimulus.Positive_failure -> "" 251 | Evidence.Stimulus.Beyond_failure (first, _) -> ( 252 match first with 253 | Evidence.Stimulus.Forced_reps -> "forced" 254 | Evidence.Stimulus.Negatives -> "negatives" 255 | Evidence.Stimulus.Rest_pause -> "rest-pause" 256 | Evidence.Stimulus.Static_hold -> "static") 257 258 (* One line describing a recorded stimulus: the efforts joined by "into", then 259 any extensions appended. *) 260 let describe_stimulus stimulus = 261 let effort effort = 262 Format.asprintf "%s %g kg x %d" 263 (Exercise.name (Evidence.Stimulus.Effort.exercise effort)) 264 (Evidence.Stimulus.Effort.load effort) 265 (Evidence.Stimulus.Effort.reps effort) 266 in 267 let body = 268 String.concat " into " 269 (List.map effort (Evidence.Stimulus.efforts stimulus)) 270 in 271 match Evidence.Stimulus.extensions stimulus with 272 | [] -> body 273 | extensions -> 274 body ^ ", " 275 ^ String.concat " then " 276 (List.map 277 (Format.asprintf "%a" Evidence.Stimulus.pp_extension) 278 extensions) 279 280 let workout_view ~active_slot workout : View_model.Workout.t = 281 let prescription = Evidence.Workout.prescription workout in 282 let prescribed_slots = Prescription.Workout.stimuli prescription in 283 (* One entry per filled slot, the first fill in performance order — the fill 284 [replace_stimulus] keeps in place when a correction lands. *) 285 let filled = 286 List.fold_left 287 (fun acc (slot, stimulus) -> 288 if List.mem_assoc slot acc then acc else acc @ [ (slot, stimulus) ]) 289 [] 290 (Evidence.Workout.performed workout) 291 in 292 let load_string load = Printf.sprintf "%g" load in 293 let reps_string reps = string_of_int reps in 294 let label p = 295 match Prescription.Stimulus.delivery p with 296 | Prescription.Stimulus.Single e -> Exercise.name e 297 | Prescription.Stimulus.Pre_exhaust { isolation; compound } -> 298 Printf.sprintf "%s + %s" (Exercise.name isolation) 299 (Exercise.name compound) 300 in 301 let slot_view index p : View_model.Workout.slot = 302 let recorded = List.assoc_opt index filled in 303 let efforts = Option.map Evidence.Stimulus.efforts recorded in 304 let nth n = Option.bind efforts (fun es -> List.nth_opt es n) in 305 let load_of n = 306 Option.map 307 (fun e -> load_string (Evidence.Stimulus.Effort.load e)) 308 (nth n) 309 in 310 let reps_of n = 311 Option.map 312 (fun e -> reps_string (Evidence.Stimulus.Effort.reps e)) 313 (nth n) 314 in 315 let movements : View_model.Workout.movement list = 316 match Prescription.Stimulus.delivery p with 317 | Prescription.Stimulus.Single exercise -> 318 [ 319 { 320 name = Exercise.name exercise; 321 load_field = "load"; 322 reps_field = "reps"; 323 reps_label = "Reps to failure"; 324 load_value = load_of 0; 325 reps_value = reps_of 0; 326 }; 327 ] 328 | Prescription.Stimulus.Pre_exhaust { isolation; compound } -> 329 [ 330 { 331 name = Exercise.name isolation; 332 load_field = "iso_load"; 333 reps_field = "iso_reps"; 334 reps_label = "Reps"; 335 load_value = load_of 0; 336 reps_value = reps_of 0; 337 }; 338 { 339 name = Exercise.name compound; 340 load_field = "comp_load"; 341 reps_field = "comp_reps"; 342 reps_label = "Reps"; 343 load_value = load_of 1; 344 reps_value = reps_of 1; 345 }; 346 ] 347 in 348 let selected_extension = 349 match recorded with 350 | None -> "" 351 | Some s -> ( 352 (* The ending lives on the last effort (the compound of a pair). *) 353 match List.rev (Evidence.Stimulus.efforts s) with 354 | last :: _ -> extension_code_of_effort last 355 | [] -> "") 356 in 357 { 358 index; 359 label = label p; 360 movements; 361 recorded_summary = Option.map describe_stimulus recorded; 362 selected_extension; 363 } 364 in 365 let overridden = 366 match Recovery.basis (Evidence.Workout.clearance workout) with 367 | Recovery.Overridden _ -> true 368 | Recovery.Recovered -> false 369 in 370 { 371 name = Prescription.Workout.name prescription; 372 slots = List.mapi slot_view prescribed_slots; 373 active_slot; 374 started_at_unix = 375 Recovery.timestamp_to_unix_seconds (Evidence.Workout.started_at workout); 376 overridden; 377 all_recorded = Evidence.Workout.outstanding workout = []; 378 } 379 380 let save_current t trainee stimulus = 381 Service.log t.service trainee stimulus >|= function 382 | Ok _ -> Ok () 383 | Error e -> Error (Present.log_error e) 384 385 let replace_current t trainee ~slot stimulus = 386 Service.replace_current t.service trainee ~slot stimulus >|= function 387 | Ok _ -> Ok () 388 | Error e -> Error (Present.log_error e) 389 390 let save_record t trainee id stimulus = 391 Service.add_to_record t.service trainee id stimulus >|= function 392 | Ok _ -> Ok () 393 | Error e -> Error (Present.edit_error e) 394 395 let replace_record t trainee id ~slot stimulus = 396 Service.replace_in_record t.service trainee id ~slot stimulus >|= function 397 | Ok _ -> Ok () 398 | Error e -> Error (Present.edit_error e) 399 400 module Overview = struct 401 (* Map service entities into the view-model DTOs the presenter renders, so 402 no Prescription or Recovery type crosses the boundary into Pages. These 403 replicate the phrasing the shared presenter formatters use. *) 404 let routine_choice_view 405 ((rid, r) : Repository.routine_id * Prescription.Routine.t) : 406 View_model.Routine_choice.t = 407 { 408 id = (rid :> string); 409 name = Prescription.Routine.name r; 410 workout_count = List.length (Prescription.Routine.workouts r); 411 } 412 413 let exercise_string prescription = 414 match Prescription.Stimulus.delivery prescription with 415 | Prescription.Stimulus.Single exercise -> Exercise.name exercise 416 | Prescription.Stimulus.Pre_exhaust { isolation; compound } -> 417 Printf.sprintf "%s into %s, no pause" (Exercise.name isolation) 418 (Exercise.name compound) 419 420 let reps_string prescription = 421 let min_reps, max_reps = 422 Prescription.Rep_range.bounds 423 (Prescription.Stimulus.rep_range prescription) 424 in 425 Printf.sprintf "%d-%d" min_reps max_reps 426 427 let routine_view r : View_model.Routine.t = 428 { 429 name = Prescription.Routine.name r; 430 workouts = 431 List.map 432 (fun w : View_model.Routine.workout -> 433 { 434 name = Prescription.Workout.name w; 435 exercises = 436 List.map 437 (fun s : View_model.Routine.exercise -> 438 { name = exercise_string s; reps = reps_string s }) 439 (Prescription.Workout.stimuli w); 440 }) 441 (Prescription.Routine.workouts r); 442 } 443 444 let duration_hours duration = 445 let hours = 446 float_of_int (Recovery.duration_to_seconds duration) /. 3600. 447 in 448 if Float.equal hours (Float.round hours) then Printf.sprintf "%.0fh" hours 449 else Printf.sprintf "%.1fh" hours 450 451 let home_view ~(routine : Repository.routine_id) ~routine_name ~next 452 ~readiness : View_model.Home.t = 453 { 454 routine_id = (routine :> string); 455 routine_name; 456 next_workout = Prescription.Workout.name next; 457 gate = 458 (match readiness with 459 | Recovery.Ready -> View_model.Home.Ready 460 | Recovery.Recovering { rested; recommended } -> 461 View_model.Home.Recovering 462 { 463 status = 464 Printf.sprintf "Recovery: %s of %s." (duration_hours rested) 465 (duration_hours recommended); 466 }); 467 } 468 469 let page t trainee request = 470 let viewer = 471 { 472 View_model.Viewer.username = 473 Trainee.username_to_string trainee.Trainee.username; 474 } 475 in 476 Service.in_progress t.service trainee.Trainee.id >>= function 477 | Some workout -> 478 let workout_name = 479 Prescription.Workout.name (Evidence.Workout.prescription workout) 480 in 481 Lwt.return (Pages.workout_in_progress request ~viewer ~workout_name) 482 | None -> ( 483 Service.active_routine t.service trainee.Trainee.id >>= function 484 | None -> 485 Lwt.return 486 (Pages.choose_routine request ~logging:false ~viewer 487 ~routines: 488 (List.map routine_choice_view 489 (Service.list_routines t.service))) 490 | Some (routine, selected) -> ( 491 Service.next_workout t.service trainee.Trainee.id ~routine 492 >>= fun next -> 493 Service.readiness t.service trainee.Trainee.id ~routine 494 ~now:(t.now ()) 495 >|= fun readiness -> 496 match (next, readiness) with 497 | Ok next, Ok readiness -> 498 Pages.home request ~viewer 499 ~home: 500 (home_view ~routine 501 ~routine_name:(Prescription.Routine.name selected) 502 ~next ~readiness) 503 | Error error, _ | _, Error error -> 504 Pages.problem ~title:"Routine unavailable" 505 ~detail:(Present.routine error))) 506 507 let home t trainee request = page t trainee request >>= html 508 509 let routines t trainee request = 510 let viewer = 511 { 512 View_model.Viewer.username = 513 Trainee.username_to_string trainee.Trainee.username; 514 } 515 in 516 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 517 html 518 (Pages.choose_routine request 519 ~logging:(Option.is_some in_progress) 520 ~viewer 521 ~routines: 522 (List.map routine_choice_view (Service.list_routines t.service))) 523 524 let select_routine t trainee request id = 525 guard_csrf request >>= function 526 | Error _ -> redirect_with_flash request Routes.home Present.form_invalid 527 | Ok () -> ( 528 Service.select_routine t.service trainee.Trainee.id 529 (Repository.routine_id id) 530 >>= function 531 | Ok () -> redirect_with_flash request Routes.home "Routine selected." 532 | Error _ -> not_found Present.unknown_routine) 533 534 let routine t trainee request = 535 let viewer = 536 { 537 View_model.Viewer.username = 538 Trainee.username_to_string trainee.Trainee.username; 539 } 540 in 541 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 542 Service.active_routine t.service trainee.Trainee.id >>= function 543 | Some (_, routine) -> 544 html 545 (Pages.routine request 546 ~logging:(Option.is_some in_progress) 547 ~viewer (routine_view routine)) 548 | None -> redirect_to request Routes.routines 549 550 let routes t = 551 [ 552 Dream_html.get Routes.home (fun request -> 553 authenticated t request (fun trainee -> home t trainee request)); 554 Dream_html.get Routes.routines (fun request -> 555 authenticated t request (fun trainee -> routines t trainee request)); 556 Dream_html.post Routes.select_routine (fun request id -> 557 authenticated t request (fun trainee -> 558 select_routine t trainee request id)); 559 Dream_html.get Routes.routine (fun request -> 560 authenticated t request (fun trainee -> routine t trainee request)); 561 ] 562 end 563 564 module Current_workout = struct 565 let begin_workout t trainee request = 566 decode_form Decode.override request >>= function 567 | Error _ -> redirect_with_flash request Routes.home Present.form_invalid 568 | Ok override -> ( 569 Service.active_routine t.service trainee.Trainee.id >>= function 570 | None -> redirect_to request Routes.routines 571 | Some (routine, _) -> ( 572 let override = if override then Some () else None in 573 Service.begin_workout t.service trainee.Trainee.id ~routine 574 ~now:(t.now ()) ?override () 575 >>= function 576 | Ok _ -> 577 redirect_with_flash request Routes.workout "Workout started." 578 | Error (Service.Not_recovered _) -> 579 redirect_with_flash request Routes.home 580 "The workout needs an explicit recovery override." 581 | Error Service.Unknown_routine -> 582 not_found Present.unknown_routine)) 583 584 let show t trainee request = 585 let viewer = 586 { 587 View_model.Viewer.username = 588 Trainee.username_to_string trainee.Trainee.username; 589 } 590 in 591 Service.in_progress t.service trainee.Trainee.id >>= function 592 | Some workout -> 593 let active_slot = active_slot request workout in 594 html 595 (Pages.workout request ~viewer ~record_id:None ~active_slot 596 (workout_view ~active_slot workout)) 597 | None -> redirect_to request Routes.home 598 599 let log t trainee request slot = 600 Service.in_progress t.service trainee.Trainee.id >>= function 601 | None -> not_found Present.no_workout 602 | Some workout -> ( 603 match outstanding workout slot with 604 | None -> not_found Present.slot_not_awaiting 605 | Some prescription -> ( 606 decode_form (Decode.stimulus prescription) request >>= function 607 | Error (`Invalid errors) -> 608 redirect_with_flash request Routes.workout 609 (Decode.errors_to_text errors) 610 | Error `Bad_request -> 611 redirect_with_flash request Routes.workout 612 Present.form_invalid 613 | Ok stimulus -> ( 614 save_current t trainee.Trainee.id stimulus >>= function 615 | Ok () -> 616 redirect_with_flash request Routes.workout "Record saved." 617 | Error detail -> 618 redirect_with_flash request Routes.workout detail))) 619 620 (* Correct a recorded slot of the workout in progress. The slot may be 621 filled, so its prescription comes from the workout, not [outstanding]. 622 [replace_current] targets the slot and never adds volume. *) 623 let edit t trainee request slot = 624 Service.in_progress t.service trainee.Trainee.id >>= function 625 | None -> not_found Present.no_workout 626 | Some workout -> ( 627 match prescription_at workout slot with 628 | None -> not_found Present.slot_not_awaiting 629 | Some prescription -> ( 630 decode_form (Decode.stimulus prescription) request >>= function 631 | Error (`Invalid errors) -> 632 redirect_with_flash request Routes.workout 633 (Decode.errors_to_text errors) 634 | Error `Bad_request -> 635 redirect_with_flash request Routes.workout 636 Present.form_invalid 637 | Ok stimulus -> ( 638 replace_current t trainee.Trainee.id ~slot stimulus 639 >>= function 640 | Ok () -> 641 redirect_with_flash request Routes.workout "Record saved." 642 | Error detail -> 643 redirect_with_flash request Routes.workout detail))) 644 645 let finish t trainee request = 646 guard_csrf request >>= function 647 | Error _ -> redirect_with_flash request Routes.home Present.form_invalid 648 | Ok () -> ( 649 Service.finish t.service trainee.Trainee.id ~ended_at:(t.now ()) 650 >>= function 651 | Some _ -> 652 redirect_with_flash_raw request "/logbook?prompt=feedback" 653 "Workout finished." 654 | None -> redirect_with_flash request Routes.home Present.no_workout) 655 656 (* Cancelling discards the current workout and returns Home. An abandoned 657 session is not evidence, so it leaves no record. A forged or missing 658 CSRF token skips the discard but still leaves the workout view. *) 659 let cancel t trainee request = 660 guard_csrf request >>= function 661 | Error _ -> 662 redirect_with_flash request Routes.home 663 "Workout cancellation was not confirmed." 664 | Ok () -> 665 Service.cancel t.service trainee.Trainee.id >>= fun _ -> 666 redirect_with_flash request Routes.home "Workout cancelled." 667 668 let routes t = 669 [ 670 Dream_html.get Routes.workout (fun request -> 671 authenticated t request (fun trainee -> show t trainee request)); 672 Dream_html.post Routes.workout (fun request -> 673 authenticated t request (fun trainee -> 674 begin_workout t trainee request)); 675 Dream_html.post Routes.workout_slot (fun request slot -> 676 authenticated t request (fun trainee -> log t trainee request slot)); 677 Dream_html.post Routes.workout_slot_edit (fun request slot -> 678 authenticated t request (fun trainee -> edit t trainee request slot)); 679 Dream_html.post Routes.finish_workout (fun request -> 680 authenticated t request (fun trainee -> finish t trainee request)); 681 Dream_html.post Routes.cancel_workout (fun request -> 682 authenticated t request (fun trainee -> cancel t trainee request)); 683 ] 684 end 685 686 module Logbook = struct 687 let feedback_session_key = "hito.feedback.flow" 688 let feedback_factors = List.map fst Pages.feedback_factors 689 690 let valid_choice = function 691 | ("1" | "2" | "3" | "4" | "5") as choice -> Some choice 692 | _ -> None 693 694 let valid_factor field = List.mem field feedback_factors 695 696 let encode_feedback_flow (flow : Pages.feedback_flow) = 697 let answers = 698 List.map (fun (field, choice) -> field ^ ":" ^ choice) flow.answers 699 in 700 Printf.sprintf "%d|%s" flow.step (String.concat ";" answers) 701 702 let decode_feedback_flow raw = 703 match String.split_on_char '|' raw with 704 | [ step; answers ] -> ( 705 match int_of_string_opt step with 706 | None -> None 707 | Some step when step < 0 || step > 5 -> None 708 | Some step -> 709 let answers = 710 answers |> String.split_on_char ';' 711 |> List.filter_map (fun answer -> 712 match String.split_on_char ':' answer with 713 | [ field; choice ] 714 when valid_factor field 715 && Option.is_some (valid_choice choice) -> 716 Some (field, choice) 717 | _ -> None) 718 in 719 Some Pages.{ step; answers }) 720 | _ -> None 721 722 let feedback_flow request = 723 Dream.session_field request feedback_session_key |> fun raw -> 724 Option.bind raw decode_feedback_flow 725 726 let initial_feedback_flow = Pages.{ step = 0; answers = [] } 727 728 let set_feedback_flow request flow = 729 Dream.set_session_field request feedback_session_key 730 (encode_feedback_flow flow) 731 732 let clear_feedback_flow request = 733 Dream.drop_session_field request feedback_session_key 734 735 let field name fields = List.assoc_opt name fields 736 737 let feedback_signals answers flags = 738 let level choice = 739 match int_of_string_opt choice with 740 | Some n -> ( 741 match Evidence.Feedback.level_of_score n with 742 | Some l -> l 743 | None -> Evidence.Feedback.Fair) 744 | None -> Evidence.Feedback.Fair 745 in 746 let leveled field make = 747 List.find_map 748 (fun (answer_field, choice) -> 749 if String.equal field answer_field then 750 Option.map 751 (fun choice -> make (level choice)) 752 (valid_choice choice) 753 else None) 754 answers 755 in 756 let optional_flag name signal = 757 match field name flags with Some "true" -> Some signal | _ -> None 758 in 759 List.filter_map Fun.id 760 [ 761 leveled "sleep" (fun l -> Evidence.Feedback.Sleep l); 762 leveled "appetite" (fun l -> Evidence.Feedback.Appetite l); 763 leveled "readiness" (fun l -> Evidence.Feedback.Readiness l); 764 leveled "motivation" (fun l -> Evidence.Feedback.Motivation l); 765 leveled "difficulty" (fun l -> Evidence.Feedback.Difficulty l); 766 optional_flag "pain" Evidence.Feedback.Pain; 767 optional_flag "injury" Evidence.Feedback.Injury; 768 optional_flag "preparation" Evidence.Feedback.Preparation_insufficient; 769 ] 770 771 (* Describe one signal for the list, and compute a factor's score for the 772 graph. These read the Evidence.Feedback entity here in the controller, so 773 the presenter receives only strings and scores. *) 774 let describe_signal (signal : Evidence.Feedback.signal) = 775 let level l = 776 let score = Evidence.Feedback.level_to_score l in 777 match l with 778 | Evidence.Feedback.Very_poor -> Printf.sprintf "%d (very poor)" score 779 | Very_good -> Printf.sprintf "%d (very good)" score 780 | _ -> string_of_int score 781 in 782 match signal with 783 | Sleep l -> Printf.sprintf "Sleep %s" (level l) 784 | Appetite l -> Printf.sprintf "Appetite %s" (level l) 785 | Readiness l -> Printf.sprintf "Readiness %s" (level l) 786 | Motivation l -> Printf.sprintf "Motivation %s" (level l) 787 | Difficulty l -> Printf.sprintf "Difficulty %s" (level l) 788 | Pain -> "Pain" 789 | Injury -> "Injury" 790 | Preparation_insufficient -> "Preparation insufficient" 791 792 let factor_fields = 793 [ "sleep"; "appetite"; "readiness"; "motivation"; "difficulty" ] 794 795 let factor_score field (report : Evidence.Feedback.t) = 796 let of_signal (signal : Evidence.Feedback.signal) = 797 match (field, signal) with 798 | "sleep", Sleep l 799 | "appetite", Appetite l 800 | "readiness", Readiness l 801 | "motivation", Motivation l 802 | "difficulty", Difficulty l -> 803 Some (Evidence.Feedback.level_to_score l) 804 | _ -> None 805 in 806 List.find_map of_signal (Evidence.Feedback.signals report) 807 808 let entry_view (record : Repository.record) : View_model.Logbook_entry.t = 809 let workout = record.Repository.workout in 810 { 811 id = (record.Repository.id :> string); 812 workout_name = 813 Prescription.Workout.name (Evidence.Workout.prescription workout); 814 stimuli_count = List.length (Evidence.Workout.stimuli workout); 815 complete = 816 (match Evidence.Workout.completeness workout with 817 | Evidence.Workout.Complete -> true 818 | Incomplete -> false); 819 } 820 821 let feedback_view (report : Evidence.Feedback.t) : 822 View_model.Feedback.report = 823 { 824 at_unix = 825 Recovery.timestamp_to_unix_seconds 826 (Evidence.Feedback.reported_at report); 827 signals = List.map describe_signal (Evidence.Feedback.signals report); 828 factor_scores = 829 List.map (fun f -> (f, factor_score f report)) factor_fields; 830 } 831 832 let index t trainee request = 833 let viewer = 834 { 835 View_model.Viewer.username = 836 Trainee.username_to_string trainee.Trainee.username; 837 } 838 in 839 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 840 Service.history t.service trainee.Trainee.id >>= fun records -> 841 Service.feedback t.service trainee.Trainee.id >>= fun feedback -> 842 let feedback = List.map feedback_view feedback in 843 let records = List.map entry_view records in 844 let saved_flow = feedback_flow request in 845 let feedback_open = 846 match 847 (Dream.query request "feedback", Dream.query request "prompt") 848 with 849 | Some "start", _ | _, Some "feedback" -> true 850 | _ -> Option.is_some saved_flow 851 in 852 html 853 (Pages.logbook request 854 ~logging:(Option.is_some in_progress) 855 ~viewer ~feedback 856 ~suggest_feedback: 857 (match Dream.query request "prompt" with 858 | Some "feedback" -> true 859 | _ -> false) 860 ~feedback_flow: 861 (Option.value saved_flow ~default:initial_feedback_flow) 862 ~feedback_open records) 863 864 let finish_feedback t trainee request flow flags = 865 let signals = feedback_signals flow.Pages.answers flags in 866 Service.record_feedback t.service trainee.Trainee.id 867 ~reported_at:(t.now ()) signals 868 >>= function 869 | Ok _ -> 870 clear_feedback_flow request >>= fun () -> 871 redirect_with_flash request Routes.logbook "Feedback saved." 872 | Error _ -> 873 clear_feedback_flow request >>= fun () -> 874 redirect_with_flash request Routes.logbook Present.form_invalid 875 876 (* Advance one factor. The answer map stays in the session until the final 877 optional-observations step, so refreshes never lose earlier choices. *) 878 let record_feedback t trainee request = 879 Dream.form request >>= function 880 | `Ok fields -> ( 881 let flow = 882 Option.value (feedback_flow request) ~default:initial_feedback_flow 883 in 884 match 885 field "step" fields |> fun raw -> 886 (Option.bind raw int_of_string_opt, flow.step) 887 with 888 | Some step, expected when step = expected -> 889 let action = 890 match field "action" fields with 891 | Some ("next" | "Next") -> Some "next" 892 | Some ("back" | "Back") -> Some "back" 893 | Some ("skip" | "Skip this factor" | "Skip this step") -> 894 Some "skip" 895 | Some ("save" | "Record feedback") -> Some "save" 896 | _ -> None 897 in 898 (* Back steps to the previous factor and keeps every answer, so a 899 trainee can revise an earlier report without losing the rest. *) 900 if action = Some "back" then 901 if step = 0 then redirect_to request Routes.logbook 902 else 903 let previous = Pages.{ flow with step = step - 1 } in 904 set_feedback_flow request previous >>= fun () -> 905 redirect_to request Routes.logbook 906 else if step = 5 then 907 if action = Some "skip" then 908 finish_feedback t trainee request flow [] 909 else if action = Some "save" then 910 finish_feedback t trainee request flow fields 911 else 912 redirect_with_flash request Routes.logbook 913 Present.form_invalid 914 else 915 let factor = List.nth feedback_factors step in 916 let answers = 917 match 918 ( action, 919 field "choice" fields |> fun raw -> 920 Option.bind raw valid_choice ) 921 with 922 | Some "next", Some choice -> (factor, choice) :: flow.answers 923 | Some "skip", _ -> flow.answers 924 | _ -> flow.answers 925 in 926 if action <> Some "next" && action <> Some "skip" then 927 redirect_with_flash request Routes.logbook 928 Present.form_invalid 929 else 930 let next = Pages.{ step = step + 1; answers } in 931 set_feedback_flow request next >>= fun () -> 932 redirect_to request Routes.logbook 933 | _ -> redirect_with_flash request Routes.logbook Present.form_invalid 934 ) 935 | _ -> redirect_with_flash request Routes.logbook Present.form_invalid 936 937 let cancel_feedback _t _trainee request = 938 guard_csrf request >>= function 939 | Error _ -> 940 redirect_with_flash request Routes.logbook Present.form_invalid 941 | Ok () -> 942 clear_feedback_flow request >>= fun () -> 943 redirect_with_flash request Routes.logbook "Feedback skipped." 944 945 let show t trainee request id = 946 let viewer = 947 { 948 View_model.Viewer.username = 949 Trainee.username_to_string trainee.Trainee.username; 950 } 951 in 952 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 953 record_target t trainee.Trainee.id id >>= function 954 | None -> not_found Present.unknown_record 955 | Some (_, workout) -> 956 let active_slot = active_slot request workout in 957 html 958 (Pages.workout request ~viewer 959 ~logging:(Option.is_some in_progress) 960 ~record_id:(Some id) ~active_slot 961 (workout_view ~active_slot workout)) 962 963 let log t trainee request id slot = 964 record_target t trainee.Trainee.id id >>= function 965 | None -> not_found Present.unknown_record 966 | Some (record_id, workout) -> ( 967 match outstanding workout slot with 968 | None -> not_found Present.slot_not_awaiting 969 | Some prescription -> ( 970 decode_form (Decode.stimulus prescription) request >>= function 971 | Error (`Invalid errors) -> 972 redirect_with_flash_attr request 973 (Dream_html.path_attr Dream_html.HTML.href Routes.record id) 974 (Decode.errors_to_text errors) 975 | Error `Bad_request -> 976 redirect_with_flash_attr request 977 (Dream_html.path_attr Dream_html.HTML.href Routes.record id) 978 Present.form_invalid 979 | Ok stimulus -> ( 980 save_record t trainee.Trainee.id record_id stimulus 981 >>= function 982 | Ok () -> 983 redirect_with_flash_attr request 984 (Dream_html.path_attr Dream_html.HTML.href Routes.record 985 id) 986 "Record saved." 987 | Error detail -> 988 redirect_with_flash_attr request 989 (Dream_html.path_attr Dream_html.HTML.href Routes.record 990 id) 991 detail))) 992 993 (* Correct a recorded slot of a saved workout. [replace_record] targets the 994 slot, replacing its record rather than appending volume. *) 995 let edit t trainee request id slot = 996 record_target t trainee.Trainee.id id >>= function 997 | None -> not_found Present.unknown_record 998 | Some (record_id, workout) -> ( 999 match prescription_at workout slot with 1000 | None -> not_found Present.slot_not_awaiting 1001 | Some prescription -> ( 1002 decode_form (Decode.stimulus prescription) request >>= function 1003 | Error (`Invalid errors) -> 1004 redirect_with_flash_attr request 1005 (Dream_html.path_attr Dream_html.HTML.href Routes.record id) 1006 (Decode.errors_to_text errors) 1007 | Error `Bad_request -> 1008 redirect_with_flash_attr request 1009 (Dream_html.path_attr Dream_html.HTML.href Routes.record id) 1010 Present.form_invalid 1011 | Ok stimulus -> ( 1012 replace_record t trainee.Trainee.id record_id ~slot stimulus 1013 >>= function 1014 | Ok () -> 1015 redirect_with_flash_attr request 1016 (Dream_html.path_attr Dream_html.HTML.href Routes.record 1017 id) 1018 "Record saved." 1019 | Error detail -> 1020 redirect_with_flash_attr request 1021 (Dream_html.path_attr Dream_html.HTML.href Routes.record 1022 id) 1023 detail))) 1024 1025 let routes t = 1026 [ 1027 Dream_html.get Routes.logbook (fun request -> 1028 authenticated t request (fun trainee -> index t trainee request)); 1029 Dream_html.post Routes.feedback (fun request -> 1030 authenticated t request (fun trainee -> 1031 record_feedback t trainee request)); 1032 Dream_html.post Routes.cancel_feedback (fun request -> 1033 authenticated t request (fun trainee -> 1034 cancel_feedback t trainee request)); 1035 Dream_html.get Routes.record (fun request id -> 1036 authenticated t request (fun trainee -> show t trainee request id)); 1037 Dream_html.post Routes.record_slot (fun request id slot -> 1038 authenticated t request (fun trainee -> 1039 log t trainee request id slot)); 1040 Dream_html.post Routes.record_slot_edit (fun request id slot -> 1041 authenticated t request (fun trainee -> 1042 edit t trainee request id slot)); 1043 ] 1044 end 1045 1046 module App_feedback = struct 1047 let tab request = 1048 match Dream.query request "tab" with 1049 | Some "submitted" -> `Submitted 1050 | _ -> `Write 1051 1052 let time_to_string timestamp = 1053 let tm = 1054 Unix.gmtime 1055 (float_of_int (Recovery.timestamp_to_unix_seconds timestamp)) 1056 in 1057 Printf.sprintf "%04d-%02d-%02d %02d:%02d" (tm.Unix.tm_year + 1900) 1058 (tm.Unix.tm_mon + 1) tm.Unix.tm_mday tm.Unix.tm_hour tm.Unix.tm_min 1059 1060 (* Map a repository record into the view model the presenter renders, so no 1061 entity or record crosses the boundary into Pages. *) 1062 let view_of (report : Repository.app_feedback) : View_model.App_feedback.t = 1063 { 1064 id = Repository.app_feedback_id_to_string report.Repository.feedback_id; 1065 author = report.Repository.author; 1066 contributions = report.Repository.contributions; 1067 submitted_at = time_to_string report.Repository.submitted_at; 1068 message = report.Repository.message; 1069 upvotes = report.Repository.upvotes; 1070 viewer_upvoted = report.Repository.viewer_upvoted; 1071 viewer_owns = report.Repository.viewer_owns; 1072 } 1073 1074 let show t trainee request = 1075 let viewer = 1076 { 1077 View_model.Viewer.username = 1078 Trainee.username_to_string trainee.Trainee.username; 1079 } 1080 in 1081 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 1082 Service.app_feedback t.service trainee.Trainee.id >>= fun reports -> 1083 html 1084 (Pages.app_feedback request 1085 ~logging:(Option.is_some in_progress) 1086 ~viewer ~tab:(tab request) (List.map view_of reports)) 1087 1088 let submit t trainee request = 1089 decode_form Decode.app_feedback request >>= function 1090 | Error _ -> 1091 redirect_with_flash_raw request "/app-feedback?tab=write" 1092 Present.form_invalid 1093 | Ok message -> ( 1094 Service.record_app_feedback t.service trainee.Trainee.id 1095 ~submitted_at:(t.now ()) ~message 1096 >>= function 1097 | Ok _ -> 1098 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1099 "Feedback submitted." 1100 | Error `Empty_message -> 1101 redirect_with_flash_raw request "/app-feedback?tab=write" 1102 Present.app_feedback_empty 1103 | Error `Unknown_feedback -> 1104 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1105 "That feedback is no longer available.") 1106 1107 let upvote t trainee request id = 1108 guard_csrf request >>= function 1109 | Error _ -> 1110 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1111 Present.form_invalid 1112 | Ok () -> 1113 Service.upvote_app_feedback t.service trainee.Trainee.id 1114 (Repository.app_feedback_id id) 1115 >>= fun added -> 1116 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1117 (if added then "Vote recorded." else "Vote was not added.") 1118 1119 let edit t trainee request id = 1120 decode_form Decode.app_feedback request >>= function 1121 | Error _ -> 1122 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1123 Present.form_invalid 1124 | Ok message -> ( 1125 Service.edit_app_feedback t.service trainee.Trainee.id 1126 (Repository.app_feedback_id id) 1127 ~message 1128 >>= function 1129 | Ok () -> 1130 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1131 "Feedback updated." 1132 | Error `Empty_message -> 1133 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1134 Present.app_feedback_empty 1135 | Error `Unknown_feedback -> 1136 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1137 "That feedback is no longer available.") 1138 1139 let remove t trainee request id = 1140 guard_csrf request >>= function 1141 | Error _ -> 1142 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1143 Present.form_invalid 1144 | Ok () -> 1145 Service.remove_app_feedback t.service trainee.Trainee.id 1146 (Repository.app_feedback_id id) 1147 >>= fun removed -> 1148 redirect_with_flash_raw request "/app-feedback?tab=submitted" 1149 (if removed then "Feedback removed." 1150 else "That feedback is no longer available.") 1151 1152 let routes t = 1153 [ 1154 Dream_html.get Routes.app_feedback (fun request -> 1155 authenticated t request (fun trainee -> show t trainee request)); 1156 Dream_html.post Routes.submit_app_feedback (fun request -> 1157 authenticated t request (fun trainee -> submit t trainee request)); 1158 Dream_html.post Routes.upvote_app_feedback (fun request id -> 1159 authenticated t request (fun trainee -> upvote t trainee request id)); 1160 Dream_html.post Routes.edit_app_feedback (fun request id -> 1161 authenticated t request (fun trainee -> edit t trainee request id)); 1162 Dream_html.post Routes.remove_app_feedback (fun request id -> 1163 authenticated t request (fun trainee -> remove t trainee request id)); 1164 ] 1165 end 1166 1167 module Profile = struct 1168 (* The profile page. A [?changed=] query set by a successful redirect shows 1169 a confirmation notice, so a refresh never re-posts a change. *) 1170 let show t trainee request = 1171 let viewer = 1172 { 1173 View_model.Viewer.username = 1174 Trainee.username_to_string trainee.Trainee.username; 1175 } 1176 in 1177 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 1178 let notice = 1179 match Dream.query request "changed" with 1180 | Some "username" -> Some Present.username_changed 1181 | Some "password" -> Some Present.password_changed 1182 | _ -> None 1183 in 1184 html 1185 (Pages.profile request 1186 ~logging:(Option.is_some in_progress) 1187 ~viewer ?notice ()) 1188 1189 let render_error t trainee request ?username_error ?password_error () = 1190 let viewer = 1191 { 1192 View_model.Viewer.username = 1193 Trainee.username_to_string trainee.Trainee.username; 1194 } 1195 in 1196 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 1197 html ~status:`Bad_Request 1198 (Pages.profile request 1199 ~logging:(Option.is_some in_progress) 1200 ~viewer ?username_error ?password_error ()) 1201 1202 let change_username t trainee request = 1203 decode_form Decode.profile_username request >>= function 1204 | Error _ -> bad_request Present.form_invalid 1205 | Ok username -> ( 1206 Service.change_username t.service trainee.Trainee.id ~username 1207 >>= function 1208 | Ok _ -> Dream.redirect request "/profile?changed=username" 1209 | Error error -> 1210 render_error t trainee request 1211 ~username_error:(Present.change_username error) 1212 ()) 1213 1214 let change_password t trainee request = 1215 decode_form Decode.profile_password request >>= function 1216 | Error _ -> bad_request Present.form_invalid 1217 | Ok (current, next) -> ( 1218 Service.change_password t.service trainee.Trainee.id ~current ~next 1219 >>= function 1220 | Ok _ -> Dream.redirect request "/profile?changed=password" 1221 | Error error -> 1222 render_error t trainee request 1223 ~password_error:(Present.change_password error) 1224 ()) 1225 1226 let routes t = 1227 [ 1228 Dream_html.get Routes.profile (fun request -> 1229 authenticated t request (fun trainee -> show t trainee request)); 1230 Dream_html.post Routes.profile_username (fun request -> 1231 authenticated t request (fun trainee -> 1232 change_username t trainee request)); 1233 Dream_html.post Routes.profile_password (fun request -> 1234 authenticated t request (fun trainee -> 1235 change_password t trainee request)); 1236 ] 1237 end 1238 1239 module Settings = struct 1240 let theme_session_key = "hito.theme" 1241 1242 let theme request = 1243 match Dream.session_field request theme_session_key with 1244 | Some "dark" -> "dark" 1245 | _ -> "light" 1246 1247 let show t trainee request = 1248 let viewer = 1249 { 1250 View_model.Viewer.username = 1251 Trainee.username_to_string trainee.Trainee.username; 1252 } 1253 in 1254 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress -> 1255 html 1256 (Pages.settings request 1257 ~logging:(Option.is_some in_progress) 1258 ~viewer ~theme:(theme request) ()) 1259 1260 let update _trainee request = 1261 guard_csrf request >>= function 1262 | Error _ -> 1263 redirect_with_flash request Routes.settings Present.form_invalid 1264 | Ok () -> ( 1265 Dream.form request >>= function 1266 | `Ok fields -> ( 1267 match List.assoc_opt "theme" fields with 1268 | Some (("light" | "dark") as value) -> 1269 Dream.set_session_field request theme_session_key value 1270 >>= fun () -> Dream.redirect request "/settings" 1271 | _ -> 1272 redirect_with_flash request Routes.settings 1273 Present.form_invalid) 1274 | _ -> 1275 redirect_with_flash request Routes.settings Present.form_invalid) 1276 1277 let routes t = 1278 [ 1279 Dream_html.get Routes.settings (fun request -> 1280 authenticated t request (fun trainee -> show t trainee request)); 1281 Dream_html.post Routes.settings_theme (fun request -> 1282 authenticated t request (fun trainee -> update trainee request)); 1283 ] 1284 end 1285 1286 module Import = struct 1287 let max_source_bytes = 10 * 1024 * 1024 1288 1289 (* Phrase an adapter warning for the view. The controller owns the wording, 1290 so no warning variant crosses into the presenter. *) 1291 let warning_view (w : Workout_import.warning) : View_model.Import_warning.t 1292 = 1293 let detail = 1294 match w.Workout_import.kind with 1295 | Workout_import.Warmup_dropped -> "warmup set dropped" 1296 | Workout_import.Unsupported_row reason -> reason 1297 in 1298 { row = w.Workout_import.row; detail } 1299 1300 let set_type_string : Workout_import.source_set_type -> string = function 1301 | Workout_import.Normal -> "normal" 1302 | Workout_import.Failure -> "failure" 1303 | Workout_import.Dropset -> "dropset" 1304 1305 (* Phrase one promotion blocker for the view. *) 1306 let blocker_string : Workout_import.blocker -> string = function 1307 | Workout_import.No_sets -> "The workout has no promotable sets." 1308 | Workout_import.Dropset_present rows -> 1309 Printf.sprintf "Drop sets cannot be promoted (rows %s)." 1310 (String.concat ", " (List.map string_of_int rows)) 1311 | Workout_import.Unmapped_exercise name -> 1312 Printf.sprintf "The exercise %S is not mapped to a catalog entry." 1313 name 1314 | Workout_import.Normal_sets_unconfirmed -> 1315 "The normal sets need a confirmation acknowledgement." 1316 1317 let set_view workout (s : Workout_import.source_set) : 1318 View_model.Import_set.t = 1319 let mapping = 1320 Workout_import.workout_mapping workout 1321 ~source_name:s.Workout_import.exercise_name 1322 |> Option.map Exercise.name 1323 in 1324 { 1325 row = s.Workout_import.row; 1326 exercise_name = s.Workout_import.exercise_name; 1327 set_type = set_type_string s.Workout_import.set_type; 1328 weight = Printf.sprintf "%g" s.Workout_import.weight_kg; 1329 reps = string_of_int s.Workout_import.reps; 1330 mapping; 1331 } 1332 1333 let workout_view (workout : Workout_import.workout) : 1334 View_model.Import_workout.t = 1335 { 1336 id = 1337 Workout_import.workout_id_to_string 1338 (Workout_import.workout_identity workout); 1339 title = Workout_import.workout_title workout; 1340 sets = List.map (set_view workout) (Workout_import.workout_sets workout); 1341 blockers = List.map blocker_string (Workout_import.blockers workout); 1342 acknowledgement = Workout_import.workout_acknowledgement workout; 1343 revision = Workout_import.workout_revision workout; 1344 } 1345 1346 let review_view (batch : Workout_import.batch) : View_model.Import_review.t 1347 = 1348 { 1349 batch_id = 1350 Workout_import.batch_id_to_string 1351 (Workout_import.batch_identity batch); 1352 warnings = List.map warning_view (Workout_import.batch_warnings batch); 1353 workouts = List.map workout_view (Workout_import.batch_workouts batch); 1354 } 1355 1356 let viewer_of (trainee : Trainee.t) = 1357 { 1358 View_model.Viewer.username = Trainee.username_to_string trainee.username; 1359 } 1360 1361 (* A distinct batch id even when a duplicate override re-imports the same 1362 source: the fingerprint prefix, the import time in seconds, and a random 1363 suffix keep two imports of one export apart, so an override never 1364 overwrites the original batch. *) 1365 let generate_batch_id ~fingerprint ~imported_at = 1366 let prefix = 1367 String.sub fingerprint 0 (min 8 (String.length fingerprint)) 1368 in 1369 let seconds = Recovery.timestamp_to_unix_seconds imported_at in 1370 let suffix = Dream.to_base64url (Dream.random 6) in 1371 Printf.sprintf "%s-%d-%s" prefix seconds suffix 1372 1373 (* Extract the single CSV file and the offset from a verified multipart 1374 form. Dream verifies the CSRF token, so a bad token never reaches here. *) 1375 let read_upload request = 1376 Dream.multipart ~csrf:true request >|= function 1377 | `Ok fields -> ( 1378 let csv = 1379 match List.assoc_opt "csv" fields with 1380 | Some ((_, content) :: _) -> Some content 1381 | _ -> None 1382 in 1383 let offset = 1384 match List.assoc_opt "utc_offset_seconds" fields with 1385 | Some ((_, raw) :: _) -> int_of_string_opt (String.trim raw) 1386 | _ -> None 1387 in 1388 let allow_duplicate = 1389 match List.assoc_opt "allow_duplicate" fields with 1390 | Some ((_, "true") :: _) -> true 1391 | _ -> false 1392 in 1393 match (csv, offset) with 1394 | Some source, Some offset -> Ok (source, offset, allow_duplicate) 1395 | _ -> Error `Bad_request) 1396 | _ -> Error `Bad_request 1397 1398 let upload t trainee request = 1399 read_upload request >>= function 1400 | Error `Bad_request -> 1401 redirect_with_flash request Routes.profile 1402 "The import upload is missing its file or offset." 1403 | Ok (source, _, _) when String.length source > max_source_bytes -> 1404 redirect_with_flash request Routes.profile 1405 "The import file exceeds the 10 MiB limit." 1406 | Ok (source, utc_offset_seconds, allow_duplicate) -> ( 1407 let fingerprint = Digest.to_hex (Digest.string source) in 1408 let imported_at = t.now () in 1409 let batch_id = 1410 Workout_import.batch_id 1411 (generate_batch_id ~fingerprint ~imported_at) 1412 in 1413 match 1414 Hevy_csv.parse ~batch_id ~fingerprint ~imported_at 1415 ~utc_offset_seconds source 1416 with 1417 | Error e -> 1418 redirect_with_flash request Routes.profile 1419 (Format.asprintf "%a" Hevy_csv.pp_error e) 1420 | Ok batch -> ( 1421 let allow_duplicate = if allow_duplicate then Some () else None in 1422 Service.store_import_batch t.service trainee.Trainee.id 1423 ?allow_duplicate batch 1424 >>= function 1425 | Ok () -> 1426 redirect_with_flash_attr request 1427 (Dream_html.path_attr Dream_html.HTML.href 1428 Routes.import_review 1429 (Workout_import.batch_id_to_string batch_id)) 1430 "Import stored. Review it below." 1431 | Error `Duplicate_source -> 1432 redirect_with_flash request Routes.profile 1433 "This export was already imported. Re-submit with the \ 1434 duplicate override to import it again." 1435 | Error _ -> 1436 redirect_with_flash request Routes.profile 1437 "The import could not be stored.")) 1438 1439 let review_target t trainee id = 1440 Service.find_import_batch t.service trainee (Workout_import.batch_id id) 1441 1442 let review_url id = 1443 Dream_html.path_attr Dream_html.HTML.href Routes.import_review id 1444 1445 let review t trainee request id = 1446 review_target t trainee.Trainee.id id >>= function 1447 | None -> not_found "That import batch no longer exists." 1448 | Some batch -> 1449 html 1450 (Pages.import_review request ~viewer:(viewer_of trainee) 1451 (review_view batch)) 1452 1453 let map t trainee request batch_id workout_id = 1454 Dream.form request >>= function 1455 | `Ok fields -> ( 1456 match 1457 ( List.assoc_opt "source_name" fields, 1458 List.assoc_opt "exercise_id" fields ) 1459 with 1460 | Some source_name, Some exercise_id -> ( 1461 match Exercise.find_id (String.trim exercise_id) with 1462 | None -> 1463 redirect_with_flash_attr request (review_url batch_id) 1464 "That exercise id is not in the catalogue." 1465 | Some exercise -> ( 1466 Service.map_import_exercise t.service trainee.Trainee.id 1467 ~batch:(Workout_import.batch_id batch_id) 1468 ~workout:(Workout_import.workout_id workout_id) 1469 ~source_name exercise 1470 >>= function 1471 | Ok _ -> 1472 redirect_with_flash_attr request (review_url batch_id) 1473 "Exercise mapped." 1474 | Error `Unknown_batch -> 1475 not_found "That import batch no longer exists." 1476 | Error _ -> 1477 redirect_with_flash_attr request (review_url batch_id) 1478 "That mapping could not be applied.")) 1479 | _ -> 1480 redirect_with_flash_attr request (review_url batch_id) 1481 Present.form_invalid) 1482 | _ -> 1483 redirect_with_flash_attr request (review_url batch_id) 1484 Present.form_invalid 1485 1486 let confirm t trainee request batch_id workout_id = 1487 Dream.form request >>= function 1488 | `Ok fields -> ( 1489 match List.assoc_opt "acknowledgement" fields with 1490 | Some acknowledgement -> ( 1491 Service.confirm_import_normal_sets t.service trainee.Trainee.id 1492 ~batch:(Workout_import.batch_id batch_id) 1493 ~workout:(Workout_import.workout_id workout_id) 1494 ~acknowledgement 1495 >>= function 1496 | Ok _ -> 1497 redirect_with_flash_attr request (review_url batch_id) 1498 "Normal sets confirmed." 1499 | Error `Unknown_batch -> 1500 not_found "That import batch no longer exists." 1501 | Error (`Import Workout_import.Empty_acknowledgement) -> 1502 redirect_with_flash_attr request (review_url batch_id) 1503 "Enter an acknowledgement before confirming." 1504 | Error _ -> 1505 redirect_with_flash_attr request (review_url batch_id) 1506 "That confirmation could not be applied.") 1507 | None -> 1508 redirect_with_flash_attr request (review_url batch_id) 1509 Present.form_invalid) 1510 | _ -> 1511 redirect_with_flash_attr request (review_url batch_id) 1512 Present.form_invalid 1513 1514 let promote_workout t trainee request batch_id workout_id = 1515 guard_csrf request >>= function 1516 | Error _ -> 1517 redirect_with_flash_attr request (review_url batch_id) 1518 Present.form_invalid 1519 | Ok () -> ( 1520 Service.promote_import_workout t.service trainee.Trainee.id 1521 ~batch:(Workout_import.batch_id batch_id) 1522 ~workout:(Workout_import.workout_id workout_id) 1523 >>= function 1524 | Ok (Workout_import.Promoted _) -> 1525 redirect_with_flash_attr request (review_url batch_id) 1526 "Workout promoted." 1527 | Ok (Workout_import.Already_promoted _) -> 1528 redirect_with_flash_attr request (review_url batch_id) 1529 "Workout was already promoted." 1530 | Ok (Workout_import.Blocked blockers) -> 1531 redirect_with_flash_attr request (review_url batch_id) 1532 (Printf.sprintf "Workout blocked: %s" 1533 (String.concat " " (List.map blocker_string blockers))) 1534 | Error `Unknown_batch -> 1535 not_found "That import batch no longer exists." 1536 | Error _ -> 1537 redirect_with_flash_attr request (review_url batch_id) 1538 "That promotion could not be applied.") 1539 1540 let promote_batch t trainee request batch_id = 1541 guard_csrf request >>= function 1542 | Error _ -> 1543 redirect_with_flash_attr request (review_url batch_id) 1544 Present.form_invalid 1545 | Ok () -> ( 1546 Service.promote_import_batch t.service trainee.Trainee.id 1547 ~batch:(Workout_import.batch_id batch_id) 1548 >>= function 1549 | Ok result -> 1550 let promoted = List.length result.Workout_import.promoted in 1551 let already = 1552 List.length result.Workout_import.already_promoted 1553 in 1554 let blocked = List.length result.Workout_import.blocked in 1555 redirect_with_flash_attr request (review_url batch_id) 1556 (Printf.sprintf "Promoted %d, already promoted %d, blocked %d." 1557 promoted already blocked) 1558 | Error `Unknown_batch -> 1559 not_found "That import batch no longer exists." 1560 | Error _ -> 1561 redirect_with_flash_attr request (review_url batch_id) 1562 "That promotion could not be applied.") 1563 1564 let routes t = 1565 [ 1566 Dream_html.post Routes.import_upload (fun request -> 1567 authenticated t request (fun trainee -> upload t trainee request)); 1568 Dream_html.get Routes.import_review (fun request id -> 1569 authenticated t request (fun trainee -> review t trainee request id)); 1570 Dream_html.post Routes.import_map (fun request batch_id workout_id -> 1571 authenticated t request (fun trainee -> 1572 map t trainee request batch_id workout_id)); 1573 Dream_html.post Routes.import_confirm 1574 (fun request batch_id workout_id -> 1575 authenticated t request (fun trainee -> 1576 confirm t trainee request batch_id workout_id)); 1577 Dream_html.post Routes.import_promote_workout 1578 (fun request batch_id workout_id -> 1579 authenticated t request (fun trainee -> 1580 promote_workout t trainee request batch_id workout_id)); 1581 Dream_html.post Routes.import_promote_batch (fun request batch_id -> 1582 authenticated t request (fun trainee -> 1583 promote_batch t trainee request batch_id)); 1584 ] 1585 end 1586 1587 module Assets = struct 1588 let stylesheet = 1589 match Stylesheet.read "hito.css" with 1590 | Some stylesheet -> stylesheet 1591 | None -> failwith "Embedded stylesheet hito.css is missing" 1592 1593 let workout_client = 1594 match Workout_client_asset.read "workout_client.js" with 1595 | Some script -> script 1596 | None -> failwith "Embedded workout client is missing" 1597 1598 let routes = 1599 [ 1600 Dream_html.get Routes.stylesheet (fun _ -> 1601 Dream.respond 1602 ~headers:[ ("Content-Type", "text/css; charset=utf-8") ] 1603 stylesheet); 1604 Dream_html.get Routes.workout_client (fun _ -> 1605 Dream.respond 1606 ~headers: 1607 [ ("Content-Type", "application/javascript; charset=utf-8") ] 1608 workout_client); 1609 ] 1610 end 1611 1612 (* A catch-all for any path no route claims. It renders the branded 404 so an 1613 unknown URL keeps the navigation and leaks nothing, rather than the empty 1614 body Dream's router would return. Kept last so it never shadows a real 1615 route. The production [error_handler] covers the rest: [5xx] responses and 1616 uncaught exceptions. *) 1617 let catch_all t = 1618 Dream.any "/**" (fun request -> 1619 current_trainee t request >>= function 1620 | Some trainee -> 1621 let viewer = 1622 { 1623 View_model.Viewer.username = 1624 Trainee.username_to_string trainee.Trainee.username; 1625 } 1626 in 1627 html ~status:`Not_Found 1628 (Pages.error_page ~viewer ~request ~status:404 ()) 1629 | None -> html ~status:`Not_Found (Pages.error_page ~status:404 ())) 1630 1631 let routes t = 1632 Auth.routes t @ Overview.routes t @ Current_workout.routes t 1633 @ Logbook.routes t @ App_feedback.routes t @ Profile.routes t 1634 @ Settings.routes t @ Import.routes t @ Assets.routes 1635 @ [ catch_all t ] 1636 1637 (* A branded error page for every failure Dream routes here: unmatched paths, 1638 [4xx] and [5xx] responses, and uncaught exceptions. The page names only the 1639 status class, never a server string, so nothing internal leaks. The status 1640 of the suggested response is preserved. *) 1641 let error_handler = 1642 Dream.error_template (fun _error _message suggested -> 1643 let status = Dream.status suggested |> Dream.status_to_int in 1644 Dream_html.set_body suggested (Pages.error_page ~status ()); 1645 Lwt.return suggested) 1646 end 1647